Votes out. A persona's downvote is a Dislike (POST /api/v1/statuses/:id/downvote and /undownvote, the viewer's own as privapub.votes.downvoted). One vote a post: a changed vote is sent as the new vote alone, as Lemmy sends one, since an Undo of the old one beside it could arrive after it and take the new one away; Undo goes only when a vote is taken back. A vote on a post in a remote community goes to the community, which counts it, and to the author only when on another server: one copy a server, since PieFed drops a second copy of an activity it has just seen. Mbin keeps a favourite apart from a vote, so there a favourite stays after a switch to a downvote. Feeds followed (owner decision 2026-10-07: through the server). Following a feed (Lemmy's multi-community, PieFed's feed) keeps a FeedSubscription for the persona and nothing else; the new Service privapub_feeds (LocalActorKind.Reader, reserved by migration _017) follows every community of the feeds read here, reconciled when a persona follows or leaves one, when a feed's list is read again (kept as it was when it cannot be read) and every six hours. Its Following rows carry FollowerKind, so nobody's home gets what it brings in and its unanswered follows are sent again as its own. The persona reads GET /api/v1/timelines/feed/:id (the feed's threads, ours included) and lists its feeds at GET /api/v1/feeds. Checked in the pasture, every scenario: 873 pass. The 14 failures are Hubzilla's (identical on the previous commit: Hubzilla no longer answers a follow in this pasture since its restore) and two activities Smithereen never sent; followsync passes once the pasture's restore is older than the 14-day pause. Live: alice's downvotes count as downvotes on Lemmy 1.0 and PieFed, replacing her upvote; Lemmy 1.0 and PieFed take privapub_feeds' follows and their threads reach the feed timelines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
133 lines
6.2 KiB
C#
133 lines
6.2 KiB
C#
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.Federation.Outbox;
|
|
using PrivaPub.Federation.Rendering;
|
|
using PrivaPub.Models.Federation;
|
|
using PrivaPub.Models.Social;
|
|
using PrivaPub.Models.User;
|
|
using PrivaPub.StaticServices;
|
|
using PrivaPub.Domain.Media;
|
|
|
|
using System.Text.Json.Nodes;
|
|
|
|
using GroupEntity = PrivaPub.Models.Group.Group;
|
|
using PostEntity = PrivaPub.Models.Post.Post;
|
|
|
|
namespace PrivaPub.Services
|
|
{
|
|
public interface IRootRemoval
|
|
{
|
|
Task<bool> Remove(string rootId, CancellationToken token);
|
|
}
|
|
|
|
// Deleting a root deletes everything public it had (owner decision 2026-10-04): each persona, and each group a persona
|
|
// owns, is announced deleted with a Delete of the actor to everyone who follows it, every member and everyone it
|
|
// follows; their posts are emptied; their names stay reserved, and their documents answer 410 from then on. The root's
|
|
// sessions end first, so nothing acts as it while it goes.
|
|
public class RootRemoval : IRootRemoval
|
|
{
|
|
readonly DbEntities _dbEntities;
|
|
readonly ILocalActorService _localActors;
|
|
readonly IDeliveryService _delivery;
|
|
readonly IRootSessions _sessions;
|
|
readonly IMediaService _media;
|
|
|
|
public RootRemoval(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery, IRootSessions sessions, IMediaService media)
|
|
{
|
|
_media = media;
|
|
_dbEntities = dbEntities;
|
|
_localActors = localActors;
|
|
_delivery = delivery;
|
|
_sessions = sessions;
|
|
}
|
|
|
|
public async Task<bool> Remove(string rootId, CancellationToken token)
|
|
{
|
|
var root = await _dbEntities.RootUsers.MatchID(rootId).Match(u => u.DeletedAt == null).ExecuteFirstAsync(token);
|
|
if (root == default)
|
|
return false;
|
|
await _sessions.Revoke(root.ID, token);
|
|
var now = DateTime.UtcNow;
|
|
|
|
var avatarIds = (await _dbEntities.RootToAvatars.Match(ra => ra.RootId == root.ID).ExecuteAsync(token)).Select(ra => ra.AvatarId).ToList();
|
|
var avatars = await _dbEntities.Avatars.Match(a => avatarIds.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync(token);
|
|
foreach (var avatar in avatars)
|
|
{
|
|
foreach (var group in await _dbEntities.Groups.Match(g => g.OwnerAvatarId == avatar.ID && !g.DeletionAt.HasValue).ExecuteAsync(token))
|
|
{
|
|
await Announce(_localActors.FromGroup(group), group.Members.Where(m => m.IsForeign).Select(m => m.AvatarId), token);
|
|
await DB.Default.Update<GroupEntity>().MatchID(group.ID).Modify(g => g.DeletionAt, now).ExecuteAsync(token);
|
|
}
|
|
var persona = _localActors.FromAvatar(avatar);
|
|
var followed = (await _dbEntities.Followings.Match(f => f.AvatarId == avatar.ID && !f.TargetIsLocal).ExecuteAsync(token))
|
|
.Select(f => f.TargetActorURI);
|
|
await Announce(persona, followed, token);
|
|
await DB.Default.Update<Avatar>().MatchID(avatar.ID).Modify(a => a.DeletionAt, now).ExecuteAsync(token);
|
|
await Empty(avatar.ID, now, token);
|
|
await DB.Default.DeleteAsync<Models.Social.PersonaListMember>(m => m.AvatarId == avatar.ID);
|
|
await DB.Default.DeleteAsync<Models.Social.PersonaList>(l => l.AvatarId == avatar.ID);
|
|
await DB.Default.DeleteAsync<Models.Social.PersonaFilter>(f => f.AvatarId == avatar.ID);
|
|
await DB.Default.DeleteAsync<Models.Social.FollowedTag>(t => t.AvatarId == avatar.ID);
|
|
// (the reader of feeds stops following what nobody reads any more at its next round)
|
|
await DB.Default.DeleteAsync<Models.Social.FeedSubscription>(s => s.AvatarId == avatar.ID);
|
|
// nothing of it publishes later, and none of its files stays served: its posts' media, its pictures and
|
|
// what its scheduled posts held
|
|
await DB.Default.DeleteAsync<Models.Social.ScheduledStatus>(s => s.AvatarId == avatar.ID);
|
|
await _media.Trash(m => m.OwnerAvatarId == avatar.ID, "root removed", token);
|
|
}
|
|
|
|
await DB.Default.Update<RootUser>().MatchID(root.ID)
|
|
.Modify(u => u.UserName, $"deleted-{root.ID}")//unique, so a second deletion never collides with the first
|
|
.Modify(u => u.Email, null)
|
|
.Modify(u => u.HashedPassword, null)
|
|
.Modify(u => u.Policies, new List<string>())
|
|
.Modify(u => u.IsBanned, false)
|
|
.Modify(u => u.IsEmailValidated, false)
|
|
.Modify(u => u.DeletedAt, now)
|
|
.ExecuteAsync(token);
|
|
await DB.Default.DeleteAsync<EmailRecovery>(r => r.RootUserId == root.ID);
|
|
return true;
|
|
}
|
|
|
|
// Delete{Actor}: to its followers' (shared) inboxes, and to the inboxes of the other accounts named
|
|
async Task Announce(LocalActor actor, IEnumerable<string> others, CancellationToken token)
|
|
{
|
|
var inboxes = (await _delivery.FollowerInboxes(actor, token)).ToList();
|
|
var named = others.Where(uri => !string.IsNullOrEmpty(uri)).Distinct().ToList();
|
|
if (named.Count > 0)
|
|
inboxes.AddRange((await _dbEntities.ForeignAvatars.Match(a => named.Contains(a.ActorURI)).ExecuteAsync(token))
|
|
.Select(a => string.IsNullOrEmpty(a.SharedInboxURL) ? a.InboxURL : a.SharedInboxURL));
|
|
await _delivery.Enqueue(actor, inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(), new JsonObject
|
|
{
|
|
["@context"] = ActivityPubRenderer.Context(),
|
|
["id"] = actor.ActivityUri("delete-actor"),
|
|
["type"] = "Delete",
|
|
["actor"] = actor.Uri,
|
|
["object"] = actor.Uri,
|
|
["to"] = new JsonArray(ActivityPubRenderer.Public),
|
|
["cc"] = new JsonArray(actor.Followers)
|
|
}, token);
|
|
}
|
|
|
|
// a persona's posts keep their ids and lose their content, like a single deleted post; a group writes none of its own
|
|
static async Task Empty(string avatarId, DateTime now, CancellationToken token)
|
|
{
|
|
var postIds = (await DB.Default.Find<PostEntity>().Match(p => p.GroupUserId == avatarId && !p.IsFederatedCopy && !p.DeletedAt.HasValue)
|
|
.Project(p => p.Include(x => x.ID)).ExecuteAsync(token)).Select(p => p.ID).ToList();
|
|
if (postIds.Count == 0)
|
|
return;
|
|
await DB.Default.Update<PostEntity>().Match(p => postIds.Contains(p.ID))
|
|
.Modify(p => p.DeletedAt, now)
|
|
.Modify(p => p.Text, null)
|
|
.Modify(p => p.ContentHtml, null)
|
|
.Modify(p => p.Title, null)
|
|
.Modify(p => p.SpoilerText, null)
|
|
.Modify(p => p.Media, new List<Models.Post.PostMedia>())
|
|
.Modify(p => p.Revisions, new List<Models.Post.PostRevision>())
|
|
.ExecuteAsync(token);
|
|
await DB.Default.DeleteAsync<TimelineEntry>(e => postIds.Contains(e.PostId) || postIds.Contains(e.ReblogOfPostId));
|
|
}
|
|
}
|
|
}
|