Pins across servers, both ways
An account elsewhere that pins or unpins one of its posts (Add or Remove on its `featured`) now shows those pins on its profile here (`pinned=true`), in its order and its public posts only; a community's announce of a moderator's Add does the same for the community. The `featured` collection itself is read with the account's counts, at most once a day, so pins made before PrivaPub ever saw an account show too. Any other target (Smithereen's wall, a community's moderators) is dropped. A persona's pin and unpin go to the post's audience as Add and Remove on /trophies, as Mastodon sends them. Checked live against Mastodon (scenarios/pins.sh, 8 checks). The town's checker learnt three peer rules from the village: Misskey and Sharkey keep a forwarded reply only with its author's LD signature (only Mastodon signs), they count no renote by a bot, and Mastodon never sees a Lemmy vote on a post in a community. The village of 2026-10-05 checks clean, 2454 of 2454. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
5f150b7a59
commit
1c7d1ece9c
23 files changed
+510
-19
No files matched your search
@@ -154,7 +154,12 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
if (Params.Bool("pinned") == true)
|
||||
{
|
||||
if (local == default)
|
||||
return Json(Array.Empty<Status>());
|
||||
{
|
||||
var featured = (await DB.Default.Find<RemoteFeatured>().Match(f => f.ActorURI == remote.ActorURI).Sort(f => f.Position, Order.Ascending)
|
||||
.Limit(Federation.Actors.FeaturedPosts.MaxFeatured).ExecuteAsync(token)).Select(f => f.PostId).ToList();
|
||||
var featuredPosts = await _dbEntities.Posts.Match(p => featured.Contains(p.ID) && !p.DeletedAt.HasValue).Match(VisibilityPolicy.IsPublic).ExecuteAsync(token);
|
||||
return Json(await _mapper.Statuses(featured.Select(id => featuredPosts.FirstOrDefault(p => p.ID == id)).Where(p => p != default).ToList(), MyId, token));
|
||||
}
|
||||
var pinIds = (await DB.Default.Find<Pin>().Match(p => p.AvatarId == local.Id).Sort(p => p.ID, Order.Descending).ExecuteAsync(token)).Select(p => p.PostId).ToList();
|
||||
var pinnedPosts = await _dbEntities.Posts.Match(p => pinIds.Contains(p.ID)).Match(VisibilityPolicy.IsPublic).ExecuteAsync(token);
|
||||
return Json(await _mapper.Statuses(pinIds.Select(id => pinnedPosts.FirstOrDefault(p => p.ID == id)).Where(p => p != default).ToList(), MyId, token));
|
||||
|
||||
@@ -405,6 +405,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
try
|
||||
{
|
||||
await DB.Default.SaveAsync(new Models.Social.Pin { AvatarId = MyId, PostId = post.ID }, token);
|
||||
await _outbox.PublishFeatured(MyId, post, featured: true, token);
|
||||
}
|
||||
catch (MongoDB.Driver.MongoWriteException ex) when (ex.WriteError?.Category == MongoDB.Driver.ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
@@ -415,7 +416,9 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
[HttpPost("/api/v1/statuses/{id}/unpin"), Scope("write:accounts")]
|
||||
public async Task<IActionResult> Unpin(string id, CancellationToken token)
|
||||
{
|
||||
await DB.Default.DeleteAsync<Models.Social.Pin>(p => p.AvatarId == MyId && p.PostId == id);
|
||||
var unpinned = await DB.Default.DeleteAsync<Models.Social.Pin>(p => p.AvatarId == MyId && p.PostId == id);
|
||||
if (unpinned is { IsAcknowledged: true, DeletedCount: > 0 } && await _dbEntities.Posts.MatchID(id).ExecuteFirstAsync(token) is { } post)
|
||||
await _outbox.PublishFeatured(MyId, post, featured: false, token);
|
||||
return await Unchanged(id, token);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,17 +10,20 @@ using PrivaPub.StaticServices;
|
||||
namespace PrivaPub.Federation.Actors
|
||||
{
|
||||
// A remote account's follower, following and post counts, as its own server publishes them (the totalItems of its
|
||||
// followers, following and outbox collections). Read when the account is fetched, at most once a day, never when
|
||||
// someone looks at it; a hidden or missing collection leaves its count unknown. Mastodon and GoToSocial do the same.
|
||||
// followers, following and outbox collections), and the posts it features. Read when the account is fetched, at most
|
||||
// once a day, never when someone looks at it; a hidden or missing collection leaves its count unknown. Mastodon and
|
||||
// GoToSocial do the same.
|
||||
public class AccountCountsJob : IJobHandler
|
||||
{
|
||||
readonly IRemoteActorService _remoteActors;
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly IFeaturedPosts _featured;
|
||||
|
||||
public AccountCountsJob(IRemoteActorService remoteActors, DbEntities dbEntities)
|
||||
public AccountCountsJob(IRemoteActorService remoteActors, DbEntities dbEntities, IFeaturedPosts featured = default)
|
||||
{
|
||||
_remoteActors = remoteActors;
|
||||
_dbEntities = dbEntities;
|
||||
_featured = featured;
|
||||
}
|
||||
|
||||
public JobKind Kind => JobKind.CountAccount;
|
||||
@@ -41,6 +44,8 @@ namespace PrivaPub.Federation.Actors
|
||||
.Modify(a => a.StatusesCount, await Total(actor, actor.OutboxURL, token))
|
||||
.Modify(a => a.CountedAt, DateTime.UtcNow)
|
||||
.ExecuteAsync(token);
|
||||
if (_featured != default)
|
||||
await _featured.Sync(actor, token);
|
||||
return JobOutcome.Done;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ namespace PrivaPub.Federation.Actors
|
||||
public string Outbox { get; init; }
|
||||
public string Followers { get; init; }
|
||||
public string Following { get; init; }
|
||||
public string Featured { get; init; }
|
||||
public string SharedInbox { get; init; }
|
||||
public string Icon { get; init; }
|
||||
public bool Discoverable { get; init; } = true;
|
||||
@@ -73,6 +74,7 @@ namespace PrivaPub.Federation.Actors
|
||||
Outbox = RemoteActorService.Text(root, "outbox"),
|
||||
Followers = RemoteActorService.Text(root, "followers"),
|
||||
Following = RemoteActorService.Text(root, "following"),
|
||||
Featured = RemoteActorService.Text(root, "featured"),
|
||||
SharedInbox = root.TryGetProperty("endpoints", out var endpoints) ? RemoteActorService.Text(endpoints, "sharedInbox") : default,
|
||||
Icon = root.TryGetProperty("icon", out var icon) ? RemoteActorService.Text(icon, "url") : default,
|
||||
Discoverable = !root.TryGetProperty("discoverable", out var discoverable) || discoverable.ValueKind != JsonValueKind.False,
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Federation.Inbox;
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Infrastructure.Http;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using static PrivaPub.Federation.Objects.ActivityJson;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Federation.Actors
|
||||
{
|
||||
public interface IFeaturedPosts
|
||||
{
|
||||
Task Receive(JsonNode activity, ForeignAvatar owner, bool add, CancellationToken token);
|
||||
Task Sync(ForeignAvatar owner, CancellationToken token);
|
||||
}
|
||||
|
||||
// What an account elsewhere features (pins): its `featured` collection, read with its counts at most once a day, and
|
||||
// the Add and Remove it sends when it pins or unpins. An account features only its own posts; a community (Lemmy's,
|
||||
// inside its announce) features posts made in it. Shown first on its profile here, as Mastodon shows them.
|
||||
public class FeaturedPosts : IFeaturedPosts
|
||||
{
|
||||
public const int MaxFeatured = 20;
|
||||
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly IRemoteActorService _remoteActors;
|
||||
readonly IRemotePosts _remotePosts;
|
||||
|
||||
public FeaturedPosts(DbEntities dbEntities, IRemoteActorService remoteActors, IRemotePosts remotePosts)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_remoteActors = remoteActors;
|
||||
_remotePosts = remotePosts;
|
||||
}
|
||||
|
||||
public async Task Receive(JsonNode activity, ForeignAvatar owner, bool add, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(owner.FeaturedURL) || Id(activity["target"]) != owner.FeaturedURL)
|
||||
{
|
||||
Arrival.Drop("unknown-target");
|
||||
return;
|
||||
}
|
||||
var objectUri = Id(activity["object"]);
|
||||
if (objectUri == default)
|
||||
{
|
||||
Arrival.Drop("unparseable");
|
||||
return;
|
||||
}
|
||||
if (!add)
|
||||
{
|
||||
var removed = await DB.Default.DeleteAsync<RemoteFeatured>(f => f.ActorURI == owner.ActorURI && f.ObjectURI == objectUri);
|
||||
if (removed is { IsAcknowledged: true, DeletedCount: > 0 })
|
||||
Arrival.Accept("unfeatured");
|
||||
else
|
||||
Arrival.Drop("unknown-object");
|
||||
return;
|
||||
}
|
||||
var post = await Featurable(owner, objectUri, token);
|
||||
if (post == default)
|
||||
{
|
||||
Arrival.Drop("unknown-object");
|
||||
return;
|
||||
}
|
||||
Arrival.About(post.ObjectType ?? "Note", post.Visibility, post.CreationDate);
|
||||
var first = await DB.Default.Find<RemoteFeatured>().Match(f => f.ActorURI == owner.ActorURI).Sort(f => f.Position, Order.Ascending).ExecuteFirstAsync(token);
|
||||
try
|
||||
{
|
||||
await DB.Default.SaveAsync(new RemoteFeatured
|
||||
{
|
||||
ActorURI = owner.ActorURI,
|
||||
ObjectURI = objectUri,
|
||||
PostId = post.ID,
|
||||
Position = (first?.Position ?? 0) - 1
|
||||
}, token);
|
||||
Arrival.Accept("featured");
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
Arrival.Drop("duplicate");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Sync(ForeignAvatar owner, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(owner.FeaturedURL) || !Origin.Same(owner.FeaturedURL, owner.ActorURI))
|
||||
return;
|
||||
var uris = await Items(owner, token);
|
||||
if (uris == default)
|
||||
return;
|
||||
var featured = new List<RemoteFeatured>();
|
||||
foreach (var uri in uris.Distinct(StringComparer.Ordinal).Take(MaxFeatured))
|
||||
if (await Featurable(owner, uri, token) is { } post)
|
||||
featured.Add(new RemoteFeatured { ActorURI = owner.ActorURI, ObjectURI = uri, PostId = post.ID, Position = featured.Count });
|
||||
await DB.Default.DeleteAsync<RemoteFeatured>(f => f.ActorURI == owner.ActorURI);
|
||||
if (featured.Count > 0)
|
||||
await DB.Default.SaveAsync(featured, token);
|
||||
}
|
||||
|
||||
// the post, held or fetched from its own server, when the owner may feature it
|
||||
async Task<PostEntity> Featurable(ForeignAvatar owner, string objectUri, CancellationToken token)
|
||||
{
|
||||
var isGroup = owner.AvatarType is AvatarType.Group;
|
||||
if (!isGroup && !Origin.Same(objectUri, owner.ActorURI))
|
||||
return default;
|
||||
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue).ExecuteFirstAsync(token)
|
||||
?? await _remotePosts.StoreContext(objectUri, 0, token);
|
||||
return post switch
|
||||
{
|
||||
null => default,
|
||||
{ ReblogOfPostId: not null } => default,
|
||||
_ when isGroup => post.AudienceURI == owner.ActorURI ? post : default,
|
||||
_ => post.ActorURI == owner.ActorURI ? post : default
|
||||
};
|
||||
}
|
||||
|
||||
// the collection's items, in its order, from its first page when it pages: links or embedded objects alike
|
||||
async Task<List<string>> Items(ForeignAvatar owner, CancellationToken token)
|
||||
{
|
||||
using var scope = HttpScope.For("collection");
|
||||
using var collection = await _remoteActors.FetchObject(owner.FeaturedURL, token);
|
||||
if (collection == default || collection.Root.ValueKind != JsonValueKind.Object)
|
||||
return default;
|
||||
var items = ItemsOf(collection.Root);
|
||||
if (items.Count > 0 || !collection.Root.TryGetProperty("first", out var first))
|
||||
return items;
|
||||
if (first.ValueKind == JsonValueKind.Object)
|
||||
return ItemsOf(first);
|
||||
var page = first.ValueKind == JsonValueKind.String ? first.GetString() : default;
|
||||
if (page == default || !Origin.Same(page, owner.ActorURI))
|
||||
return items;
|
||||
using var fetched = await _remoteActors.FetchObject(page, token);
|
||||
return fetched == default || fetched.Root.ValueKind != JsonValueKind.Object ? items : ItemsOf(fetched.Root);
|
||||
}
|
||||
|
||||
static List<string> ItemsOf(JsonElement collection)
|
||||
{
|
||||
var items = new List<string>();
|
||||
foreach (var name in new[] { "orderedItems", "items" })
|
||||
if (collection.TryGetProperty(name, out var list) && list.ValueKind == JsonValueKind.Array)
|
||||
foreach (var item in list.EnumerateArray())
|
||||
if (item.ValueKind == JsonValueKind.String)
|
||||
items.Add(item.GetString());
|
||||
else if (item.ValueKind == JsonValueKind.Object && item.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.String)
|
||||
items.Add(id.GetString());
|
||||
return items;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,6 +204,7 @@ namespace PrivaPub.Federation.Actors
|
||||
.Modify(a => a.OutboxURL, actor.Outbox)
|
||||
.Modify(a => a.FollowersURL, actor.Followers)
|
||||
.Modify(a => a.FollowingURL, actor.Following)
|
||||
.Modify(a => a.FeaturedURL, Origin.Same(actor.Featured, actor.Id) ? actor.Featured : default)
|
||||
.Modify(a => a.SharedInboxURL, actor.SharedInbox)
|
||||
.Modify(a => a.PictureURL, actor.Icon)
|
||||
.Modify(a => a.ThumbnailURL, actor.Header)
|
||||
|
||||
@@ -228,6 +228,15 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
case "Like" or "Dislike" or "Undo":
|
||||
await Relayed(inner, group, token);
|
||||
break;
|
||||
// a moderator features a post in the community, or no longer: the community's own pins
|
||||
case "Add" or "Remove" when Id(inner["target"]) is { } target && target == group.FeaturedURL:
|
||||
var featured = (Relays ?? _services?.GetService(typeof(IEnumerable<IActivityHandler>)) as IEnumerable<IActivityHandler>)
|
||||
?.FirstOrDefault(h => h.Type == Value(inner, "type"));
|
||||
if (featured == default)
|
||||
Arrival.Drop("unsupported");
|
||||
else
|
||||
await featured.Handle(inner, group, token);
|
||||
break;
|
||||
default:
|
||||
Arrival.Drop("unsupported");
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Models.User;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Federation.Inbox.Handlers
|
||||
{
|
||||
// An account pins one of its posts (Add to its `featured` collection) or unpins it (Remove), as Mastodon and Akkoma
|
||||
// send it; inside a community's announce, the community features a post made in it. Any other target (Smithereen's
|
||||
// wall, a community's moderators) is not ours to keep.
|
||||
public class FeaturedHandler : IActivityHandler
|
||||
{
|
||||
readonly IFeaturedPosts _featured;
|
||||
readonly bool _add;
|
||||
|
||||
public FeaturedHandler(IFeaturedPosts featured, bool add)
|
||||
{
|
||||
_featured = featured;
|
||||
_add = add;
|
||||
}
|
||||
|
||||
public string Type => _add ? "Add" : "Remove";
|
||||
|
||||
public Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) => _featured.Receive(activity, actor, _add, token);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ namespace PrivaPub.Federation.Outbox
|
||||
Task Publish(LocalActor author, PostEntity post, JsonObject activity, CancellationToken token);
|
||||
Task PublishUpdate(LocalActor author, PostEntity post, string reason, CancellationToken token);
|
||||
Task PublishProfile(LocalActor actor, CancellationToken token);
|
||||
Task PublishFeatured(string personaId, PostEntity post, bool featured, CancellationToken token);
|
||||
}
|
||||
|
||||
public class OutboxPublisher : IOutboxPublisher
|
||||
@@ -110,6 +111,26 @@ namespace PrivaPub.Federation.Outbox
|
||||
await _delivery.Enqueue(author, inboxes, activity, token);
|
||||
}
|
||||
|
||||
// a persona pins its post or unpins it: Add or Remove on its featured collection (/trophies, which serves the same
|
||||
// pins), told to the post's audience as Mastodon tells it
|
||||
public async Task PublishFeatured(string personaId, PostEntity post, bool featured, CancellationToken token)
|
||||
{
|
||||
var author = await _localActors.FindById(LocalActorKind.Person, personaId, token);
|
||||
if (author is not { IsFederated: true } || post.IsLocalOnly)
|
||||
return;
|
||||
await Publish(author, post, new JsonObject
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.Context(),
|
||||
["id"] = author.ActivityUri($"{(featured ? "feature" : "unfeature")}-{post.ID}-{DateTime.UtcNow.Ticks}"),
|
||||
["type"] = featured ? "Add" : "Remove",
|
||||
["actor"] = author.Uri,
|
||||
["object"] = author.PostUri(post.ID),
|
||||
["target"] = author.Featured,
|
||||
["to"] = new JsonArray(Objects.Addressing.Public),
|
||||
["cc"] = new JsonArray(author.Followers)
|
||||
}, token);
|
||||
}
|
||||
|
||||
public async Task PublishUpdate(LocalActor author, PostEntity post, string reason, CancellationToken token)
|
||||
{
|
||||
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
|
||||
|
||||
@@ -121,7 +121,8 @@ namespace PrivaPub.Infrastructure.Data
|
||||
(() => DB.Default.Index<Mute>().Key(m => m.AvatarId, KeyType.Ascending).Key(m => m.TargetActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "mute"),
|
||||
(() => DB.Default.Index<AccountDomainBlock>().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.Domain, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "domain block"),
|
||||
(() => DB.Default.Index<Bookmark>().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.PostId, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "bookmark"),
|
||||
(() => DB.Default.Index<Pin>().Key(p => p.AvatarId, KeyType.Ascending).Key(p => p.PostId, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "pin")
|
||||
(() => DB.Default.Index<Pin>().Key(p => p.AvatarId, KeyType.Ascending).Key(p => p.PostId, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "pin"),
|
||||
(() => DB.Default.Index<RemoteFeatured>().Key(f => f.ActorURI, KeyType.Ascending).Key(f => f.ObjectURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "remote-featured")
|
||||
})
|
||||
await pair.Item1();
|
||||
await Plain<Block>(token, b => b.TargetActorURI);
|
||||
|
||||
@@ -94,6 +94,9 @@ namespace PrivaPub.Middleware
|
||||
.AddSingleton<IActivityHandler, FlagHandler>()
|
||||
.AddSingleton<IActivityHandler, BlockHandler>()
|
||||
.AddSingleton<IActivityHandler, LockHandler>()
|
||||
.AddSingleton<Federation.Actors.IFeaturedPosts, Federation.Actors.FeaturedPosts>()
|
||||
.AddSingleton<IActivityHandler>(services => new FeaturedHandler(services.GetRequiredService<Federation.Actors.IFeaturedPosts>(), add: true))
|
||||
.AddSingleton<IActivityHandler>(services => new FeaturedHandler(services.GetRequiredService<Federation.Actors.IFeaturedPosts>(), add: false))
|
||||
.AddSingleton<IActivityHandler, MoveHandler>()
|
||||
.AddSingleton<IActivityHandler, CreateHandler>()
|
||||
.AddSingleton<IActivityHandler, DeleteHandler>()
|
||||
|
||||
@@ -21,6 +21,17 @@ namespace PrivaPub.Models.Social
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
//a post an account elsewhere features (its `featured` collection, Add and Remove), shown first on its profile here;
|
||||
//the lowest Position first
|
||||
public class RemoteFeatured : Entity
|
||||
{
|
||||
public string ActorURI { get; set; }
|
||||
public string ObjectURI { get; set; }
|
||||
public string PostId { get; set; }
|
||||
public int Position { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class Mute : Entity
|
||||
{
|
||||
public string AvatarId { get; set; }
|
||||
|
||||
@@ -75,6 +75,7 @@ namespace PrivaPub.Models.User
|
||||
public string SharedInboxURL { get; set; }
|
||||
public string InboxURL { get; set; }
|
||||
public string OutboxURL { get; set; }
|
||||
public string FeaturedURL { get; set; }//featured: the posts it pins
|
||||
public string MovedToURL { get; set; }
|
||||
public List<string> AlsoKnownAs { get; set; } = new();//alsoKnownAs: the accounts it says it also is
|
||||
public string PictureURL { get; set; }//icon
|
||||
|
||||
Reference in new issue
Block a user