Owner decision 2026-10-04: fix the account privacy findings.
- Sign-in. Every failure answers "That username and password do not match." after the same work: an unknown login
is hashed against a decoy, and the comparison is constant-time. "Banned" is told only to someone who gave the right
password. This covers /clientapi/user/login, /invitation/login and /oauth/login.
- Recovery.
- Every request answers the same sentence and queues a SendRecovery job, whether or not the account exists or has an
email. The lookup, the code and SMTP move to RecoveryJob, so neither the answer nor its timing says anything.
- Codes are kept only as a SHA-256 hash, for one hour. Migration _011 drops the plaintext ones, which never expired.
- A recovered password ends every session of the root. RootSessions sets CredentialsChangedAt, which JwtEvents
checks against the JWT's issue time, now stamped as nbf, and revokes each persona's OAuth tokens and authorizations.
- Deleting a root (RootRemoval: the admin route, or the restored self-delete at /clientapi/user/delete, which asks for
the password).
- Its sessions end.
- Each persona and each group it owns sends Delete{Actor} to its followers, its members and the accounts it follows.
- The personas' posts are emptied.
- /peasants/{name} answers 410 with a Tombstone (formerType Person or Group), as do its inbox and WebFinger, through
LocalActorService.Gone. The names stay reserved.
- The root keeps only a unique `deleted-{id}` name; the second deletion on an instance used to collide on
"Deleted user".
Also, from phase 2's pasture: GoToSocial files a circle post like a DM and shows it only to accounts it mentions. Each
member's copy, and a member's refetch, now also mentions that member silently. The GoToSocial scenario checks circle
posts in conversations, like DMs, and they pass there now, as on Mastodon.
657 tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
169 lines
7.0 KiB
C#
169 lines
7.0 KiB
C#
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.Federation.Rendering;
|
|
using PrivaPub.Models.Federation;
|
|
using PrivaPub.Models.Post;
|
|
using PrivaPub.Models.User;
|
|
using PrivaPub.StaticServices;
|
|
|
|
using System.Text.Json.Nodes;
|
|
|
|
using PostEntity = PrivaPub.Models.Post.Post;
|
|
|
|
namespace PrivaPub.Federation.Outbox
|
|
{
|
|
public interface IOutboxPublisher
|
|
{
|
|
Task<IReadOnlyList<string>> Audience(LocalActor author, PostEntity post, CancellationToken token);
|
|
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);
|
|
}
|
|
|
|
public class OutboxPublisher : IOutboxPublisher
|
|
{
|
|
readonly DbEntities _dbEntities;
|
|
readonly ILocalActorService _localActors;
|
|
readonly IDeliveryService _delivery;
|
|
|
|
public OutboxPublisher(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery)
|
|
{
|
|
_dbEntities = dbEntities;
|
|
_localActors = localActors;
|
|
_delivery = delivery;
|
|
}
|
|
|
|
public async Task<IReadOnlyList<string>> Audience(LocalActor author, PostEntity post, CancellationToken token)
|
|
{
|
|
if (post.Visibility == PostVisibility.LocalGeo || post.IsLocalOnly)
|
|
return Array.Empty<string>();
|
|
if (post.Visibility == PostVisibility.Circle)
|
|
return await CircleMembers(post.GroupId, token);
|
|
|
|
var inboxes = new List<string>();
|
|
if (post.Visibility is PostVisibility.Public or PostVisibility.Unlisted or PostVisibility.FollowersOnly)
|
|
inboxes.AddRange(await _delivery.FollowerInboxes(author, token));
|
|
|
|
var addressed = post.Mentions.Where(m => !m.IsLocal).Select(m => m.ActorURI)
|
|
.Concat(post.Visibility == PostVisibility.Direct ? post.To.Concat(post.Cc) : Enumerable.Empty<string>())
|
|
.Where(uri => !uri.StartsWith(author.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
|
|
.Distinct(StringComparer.Ordinal)
|
|
.ToList();
|
|
foreach (var uri in addressed)
|
|
{
|
|
var actor = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == uri).ExecuteFirstAsync(token);
|
|
if (actor != default && !string.IsNullOrEmpty(actor.InboxURL))
|
|
inboxes.Add(actor.InboxURL);
|
|
}
|
|
|
|
if (post.Visibility != PostVisibility.Direct && !string.IsNullOrEmpty(post.InReplyToAccountId))
|
|
{
|
|
var parentAuthor = await _dbEntities.ForeignAvatars.MatchID(post.InReplyToAccountId).ExecuteFirstAsync(token);
|
|
if (parentAuthor != default && !string.IsNullOrEmpty(parentAuthor.InboxURL))
|
|
inboxes.Add(parentAuthor.InboxURL);
|
|
}
|
|
|
|
if (post.Visibility is PostVisibility.Public or PostVisibility.Unlisted && !string.IsNullOrEmpty(post.QuotedPostId))
|
|
{
|
|
var quoted = await _dbEntities.Posts.MatchID(post.QuotedPostId).ExecuteFirstAsync(token);
|
|
var quotedAuthor = quoted is { IsFederatedCopy: true }
|
|
? await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == quoted.ActorURI).ExecuteFirstAsync(token)
|
|
: default;
|
|
if (!string.IsNullOrEmpty(quotedAuthor?.InboxURL))
|
|
inboxes.Add(quotedAuthor.InboxURL);
|
|
}
|
|
|
|
return inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList();
|
|
}
|
|
|
|
async Task<IReadOnlyList<string>> CircleMembers(string groupId, CancellationToken token) =>
|
|
(await CircleRecipients(groupId, token)).Select(r => r.InboxURL).Distinct(StringComparer.Ordinal).ToList();
|
|
|
|
async Task<IReadOnlyList<ForeignAvatar>> CircleRecipients(string groupId, CancellationToken token)
|
|
{
|
|
var circle = string.IsNullOrEmpty(groupId) ? default : await _dbEntities.Groups.MatchID(groupId).ExecuteFirstAsync(token);
|
|
if (circle == default)
|
|
return Array.Empty<ForeignAvatar>();
|
|
var remote = circle.Members.Where(m => m.IsForeign).Select(m => m.AvatarId).ToList();
|
|
if (remote.Count == 0)
|
|
return Array.Empty<ForeignAvatar>();
|
|
return (await _dbEntities.ForeignAvatars.Match(a => remote.Contains(a.ActorURI)).ExecuteAsync(token))
|
|
.Where(a => !string.IsNullOrEmpty(a.InboxURL))
|
|
.ToList();
|
|
}
|
|
|
|
public async Task Publish(LocalActor author, PostEntity post, JsonObject activity, CancellationToken token)
|
|
{
|
|
if (post.Visibility == PostVisibility.Circle)
|
|
{
|
|
foreach (var member in await CircleRecipients(post.GroupId, token))
|
|
await _delivery.Enqueue(author, new[] { member.InboxURL }, Naming(activity, new[] { member }), token);
|
|
return;
|
|
}
|
|
var inboxes = await Audience(author, post, token);
|
|
if (inboxes.Count > 0)
|
|
await _delivery.Enqueue(author, inboxes, activity, 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);
|
|
await Publish(author, post, ActivityPubRenderer.UpdateOf(post, author, group, reason), token);
|
|
}
|
|
|
|
// Mastodon keeps a post only when it names one of its own accounts, and GoToSocial shows a post that is neither
|
|
// public nor for followers only to the accounts it mentions; a circle post names only the circle. So each member's
|
|
// copy, or a member's refetch, also names that member in cc and mentions them, silently, in its tags (owner decision
|
|
// 2026-10-04): it tells each member nothing but that they are in the circle.
|
|
public static JsonObject Naming(JsonObject activityOrObject, IEnumerable<ForeignAvatar> members)
|
|
{
|
|
var named = members.ToList();
|
|
var copy = (JsonObject)activityOrObject.DeepClone();
|
|
if (copy["object"] is JsonObject inner)
|
|
{
|
|
Name(copy, named, mention: false);
|
|
if (inner.ContainsKey("to"))
|
|
Name(inner, named, mention: true);
|
|
}
|
|
else
|
|
Name(copy, named, mention: copy.ContainsKey("attributedTo"));
|
|
return copy;
|
|
}
|
|
|
|
static void Name(JsonObject node, IReadOnlyList<ForeignAvatar> members, bool mention)
|
|
{
|
|
var cc = node["cc"] as JsonArray ?? new JsonArray();
|
|
var tags = node["tag"] as JsonArray ?? new JsonArray();
|
|
foreach (var member in members)
|
|
{
|
|
if (!cc.Any(c => c?.GetValue<string>() == member.ActorURI))
|
|
cc.Add(member.ActorURI);
|
|
if (mention && !tags.Any(t => t?["href"]?.GetValue<string>() == member.ActorURI))
|
|
tags.Add(new JsonObject { ["type"] = "Mention", ["href"] = member.ActorURI, ["name"] = Handle(member) });
|
|
}
|
|
node["cc"] = cc;
|
|
if (mention)
|
|
node["tag"] = tags;
|
|
}
|
|
|
|
static string Handle(ForeignAvatar member) =>
|
|
string.IsNullOrEmpty(member.UserName) ? member.ActorURI : $"@{member.UserName}@{new Uri(member.ActorURI).Authority}";
|
|
|
|
public async Task PublishProfile(LocalActor actor, CancellationToken token)
|
|
{
|
|
var document = ActivityPubRenderer.Actor(actor);
|
|
document.Remove("@context");
|
|
var update = new JsonObject
|
|
{
|
|
["@context"] = ActivityPubRenderer.Context(),
|
|
["id"] = actor.ActivityUri($"update-profile-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"),
|
|
["type"] = "Update",
|
|
["actor"] = actor.Uri,
|
|
["to"] = new JsonArray(ActivityPubRenderer.Public),
|
|
["cc"] = new JsonArray(actor.Followers),
|
|
["object"] = document
|
|
};
|
|
await _delivery.EnqueueToFollowers(actor, update, token);
|
|
}
|
|
}
|
|
}
|