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
370 lines
15 KiB
C#
370 lines
15 KiB
C#
using Microsoft.Extensions.Options;
|
|
|
|
using MongoDB.Driver;
|
|
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Models;
|
|
using PrivaPub.Models.Federation;
|
|
using PrivaPub.Models.Group;
|
|
using PrivaPub.Models.User;
|
|
using PrivaPub.StaticServices;
|
|
|
|
using System.Security.Cryptography;
|
|
|
|
using GroupEntity = PrivaPub.Models.Group.Group;
|
|
|
|
namespace PrivaPub.Federation.Actors
|
|
{
|
|
public class LocalActor
|
|
{
|
|
public string Id { get; init; }
|
|
public LocalActorKind Kind { get; init; }
|
|
public string UserName { get; init; }
|
|
public string Name { get; init; }
|
|
public string Summary { get; init; }
|
|
public string PictureURL { get; init; }
|
|
public string ThumbnailURL { get; init; }
|
|
public string PrivateKeyPem { get; init; }
|
|
public string PublicKeyPem { get; init; }
|
|
public string SigningKey { get; init; }//a persona's Ed25519 seed (FEP-521a), for the proofs its activities carry (FEP-8b32)
|
|
public bool Discoverable { get; init; } = true;
|
|
public bool ManuallyApprovesFollowers { get; init; }
|
|
public bool IsFederated { get; init; } = true;
|
|
public bool IsCircle { get; init; }
|
|
public bool PostingRestrictedToModerators { get; init; }
|
|
public bool IsBot { get; init; }
|
|
public bool Indexable { get; init; }
|
|
public AvatarSettings Settings { get; init; } = new();
|
|
public DateTime Published { get; init; }
|
|
public string BaseAddress { get; init; }
|
|
public IReadOnlyDictionary<string, string> Fields { get; init; } = new Dictionary<string, string>();
|
|
|
|
public string Uri => $"{BaseAddress}/peasants/{UserName}";
|
|
public string KeyId => $"{Uri}#main-key";
|
|
public string AssertionKeyId => $"{Uri}#ed25519-key";
|
|
public string Inbox => $"{Uri}/mouth";
|
|
public string Outbox => $"{Uri}/anus";
|
|
public string Followers => $"{Uri}/groupies";
|
|
public string Following => $"{Uri}/stalking";
|
|
public string Featured => $"{Uri}/trophies";
|
|
public string FeaturedTags => $"{Uri}/tattoos";
|
|
public string Wall => $"{Uri}/graffiti";
|
|
public bool HasWall => Kind == LocalActorKind.Person && !IsCircle && IsFederated;
|
|
// the server's own actors (the instance actor, the reporter): nobody follows, mentions or looks them up as accounts
|
|
public bool IsServerActor => Kind is LocalActorKind.Application or LocalActorKind.Reporter or LocalActorKind.Reader;
|
|
public string Flock => $"{Uri}/flock";
|
|
public string Wardens => $"{Uri}/wardens";
|
|
public string SharedInbox => $"{BaseAddress}/human-centipede";
|
|
public string Domain => new Uri(BaseAddress).Authority;
|
|
public string Handle => $"{UserName}@{Domain}";
|
|
public string HtmlUrl => $"{BaseAddress}/@{UserName}";
|
|
public string PostUri(string postId) => $"{Uri}/scribbles/{postId}";
|
|
public string PostHtmlUrl(string postId) => $"{HtmlUrl}/{postId}";
|
|
public string ActivityUri(string activityId) => $"{Uri}/grunts/{activityId}";
|
|
public string ConversationUri(string conversationId) => $"{Uri}/whispers/{conversationId}";
|
|
}
|
|
|
|
public interface ILocalActorService
|
|
{
|
|
string BaseAddress { get; }
|
|
Task<LocalActor> FindByUserName(string userName, CancellationToken token);
|
|
Task<GoneActor> Gone(string userName, CancellationToken token);
|
|
Task<LocalActor> FindById(LocalActorKind kind, string id, CancellationToken token);
|
|
Task<LocalActor> FindByUri(string actorUri, CancellationToken token);
|
|
Task<LocalActor> FindByAddress(string address, CancellationToken token);
|
|
Task<LocalActor> GetInstanceActor(CancellationToken token);
|
|
Task<LocalActor> GetReporterActor(CancellationToken token);
|
|
Task<LocalActor> GetReaderActor(CancellationToken token);
|
|
Task<bool> IsUserNameTaken(string userName, CancellationToken token);
|
|
Task<bool> TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token);
|
|
LocalActor FromAvatar(Avatar avatar);
|
|
LocalActor FromGroup(GroupEntity group);
|
|
}
|
|
|
|
// a persona or group that was deleted: its name stays reserved, and its documents answer 410 with this
|
|
public sealed record GoneActor(string Uri, string FormerType, DateTime DeletedAt);
|
|
|
|
public class LocalActorService : ILocalActorService
|
|
{
|
|
public const string InstanceUserName = "privapub";
|
|
// the anonymous Service that carries this server's reports to Lemmy, which takes no report from an Application
|
|
// (owner decision 2026-10-06): one actor for the server, never one per persona
|
|
public const string ReporterUserName = "privapub_reports";
|
|
// the anonymous Service that follows the communities of the feeds personas read, so no community learns which persona
|
|
// reads it (owner decision 2026-10-07); a Service, since Lemmy takes a follow from no Application
|
|
public const string ReaderUserName = "privapub_feeds";
|
|
|
|
public static bool IsServerActorName(string userName) =>
|
|
string.Equals(userName, InstanceUserName, StringComparison.OrdinalIgnoreCase) || string.Equals(userName, ReporterUserName, StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(userName, ReaderUserName, StringComparison.OrdinalIgnoreCase);
|
|
|
|
static readonly HashSet<string> ReservedByInstance = new(StringComparer.Ordinal)
|
|
{
|
|
InstanceUserName, ReporterUserName, ReaderUserName, "admin", "administrator", "root", "system", "support", "help", "moderator", "mod",
|
|
"abuse", "postmaster", "webmaster", "hostmaster", "security", "noreply", "no_reply", "null", "undefined"
|
|
};
|
|
|
|
readonly DbEntities _dbEntities;
|
|
readonly IOptionsMonitor<AppConfiguration> _appConfiguration;
|
|
InstanceActor _instanceActor;
|
|
ReporterActor _reporterActor;
|
|
ReaderActor _readerActor;
|
|
|
|
public LocalActorService(DbEntities dbEntities, IOptionsMonitor<AppConfiguration> appConfiguration)
|
|
{
|
|
_dbEntities = dbEntities;
|
|
_appConfiguration = appConfiguration;
|
|
}
|
|
|
|
public string BaseAddress => _appConfiguration.CurrentValue.BackendBaseAddress?.TrimEnd('/');
|
|
|
|
public async Task<LocalActor> FindByUserName(string userName, CancellationToken token)
|
|
{
|
|
if (string.IsNullOrEmpty(userName))
|
|
return default;
|
|
userName = userName.ToLowerInvariant();
|
|
if (userName == InstanceUserName)
|
|
return await GetInstanceActor(token);
|
|
if (userName == ReporterUserName)
|
|
return await GetReporterActor(token);
|
|
if (userName == ReaderUserName)
|
|
return await GetReaderActor(token);
|
|
|
|
var avatar = await _dbEntities.Avatars
|
|
.Match(a => a.UserName == userName && !a.DeletionAt.HasValue)
|
|
.ExecuteFirstAsync(token);
|
|
if (avatar != default)
|
|
return FromAvatar(avatar);
|
|
|
|
var group = await _dbEntities.Groups
|
|
.Match(g => g.UserName == userName && !g.DeletionAt.HasValue)
|
|
.ExecuteFirstAsync(token);
|
|
return group == default ? default : FromGroup(group);
|
|
}
|
|
|
|
public async Task<GoneActor> Gone(string userName, CancellationToken token)
|
|
{
|
|
if (string.IsNullOrEmpty(userName))
|
|
return default;
|
|
userName = userName.ToLowerInvariant();
|
|
var avatar = await _dbEntities.Avatars.Match(a => a.UserName == userName && a.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
|
if (avatar != default)
|
|
return new GoneActor($"{BaseAddress}/peasants/{avatar.UserName}", "Person", avatar.DeletionAt.Value);
|
|
var group = await _dbEntities.Groups.Match(g => g.UserName == userName && g.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
|
return group == default ? default : new GoneActor($"{BaseAddress}/peasants/{group.UserName}", "Group", group.DeletionAt.Value);
|
|
}
|
|
|
|
public async Task<LocalActor> FindById(LocalActorKind kind, string id, CancellationToken token)
|
|
{
|
|
switch (kind)
|
|
{
|
|
case LocalActorKind.Person:
|
|
var avatar = await _dbEntities.Avatars.MatchID(id).ExecuteFirstAsync(token);
|
|
return avatar == default ? default : FromAvatar(avatar);
|
|
case LocalActorKind.Group:
|
|
var group = await _dbEntities.Groups.MatchID(id).ExecuteFirstAsync(token);
|
|
return group == default ? default : FromGroup(group);
|
|
case LocalActorKind.Reporter:
|
|
return await GetReporterActor(token);
|
|
case LocalActorKind.Reader:
|
|
return await GetReaderActor(token);
|
|
default:
|
|
return await GetInstanceActor(token);
|
|
}
|
|
}
|
|
|
|
public Task<LocalActor> FindByUri(string actorUri, CancellationToken token)
|
|
{
|
|
var prefix = $"{BaseAddress}/peasants/";
|
|
if (string.IsNullOrEmpty(actorUri) || !actorUri.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
|
return Task.FromResult<LocalActor>(default);
|
|
var userName = actorUri[prefix.Length..].Split('/', '#', '?')[0];
|
|
return FindByUserName(userName, token);
|
|
}
|
|
|
|
// an actor id, or the profile page its document names as `url`: Mbin addresses its private messages to that page
|
|
public Task<LocalActor> FindByAddress(string address, CancellationToken token)
|
|
{
|
|
var profile = $"{BaseAddress}/@";
|
|
if (string.IsNullOrEmpty(address) || !address.StartsWith(profile, StringComparison.OrdinalIgnoreCase))
|
|
return FindByUri(address, token);
|
|
var userName = address[profile.Length..];
|
|
return userName.Length == 0 || userName.IndexOfAny(['/', '#', '?']) >= 0 ? Task.FromResult<LocalActor>(default) : FindByUserName(userName, token);
|
|
}
|
|
|
|
public async Task<LocalActor> GetInstanceActor(CancellationToken token)
|
|
{
|
|
var instance = _instanceActor ??= await LoadInstanceActor(token);
|
|
|
|
return new LocalActor
|
|
{
|
|
Id = instance.ID,
|
|
Kind = LocalActorKind.Application,
|
|
UserName = InstanceUserName,
|
|
Name = "PrivaPub",
|
|
Summary = "The instance actor of this PrivaPub server; it signs the requests no other actor speaks for.",
|
|
PrivateKeyPem = instance.PrivateKey,
|
|
PublicKeyPem = instance.PublicKey,
|
|
Discoverable = false,
|
|
Published = instance.CreationDate,
|
|
BaseAddress = BaseAddress
|
|
};
|
|
}
|
|
|
|
public async Task<LocalActor> GetReporterActor(CancellationToken token)
|
|
{
|
|
var reporter = _reporterActor ??= await LoadReporterActor(token);
|
|
|
|
return new LocalActor
|
|
{
|
|
Id = reporter.ID,
|
|
Kind = LocalActorKind.Reporter,
|
|
UserName = ReporterUserName,
|
|
Name = $"Reports from {new Uri(BaseAddress).Host}",
|
|
Summary = "It carries this PrivaPub server's reports to the servers that take them only from a person or a service; "
|
|
+ "it never names who made them.",
|
|
PrivateKeyPem = reporter.PrivateKey,
|
|
PublicKeyPem = reporter.PublicKey,
|
|
Discoverable = false,
|
|
Published = reporter.CreationDate,
|
|
BaseAddress = BaseAddress
|
|
};
|
|
}
|
|
|
|
public async Task<LocalActor> GetReaderActor(CancellationToken token)
|
|
{
|
|
var reader = _readerActor ??= await LoadReaderActor(token);
|
|
|
|
return new LocalActor
|
|
{
|
|
Id = reader.ID,
|
|
Kind = LocalActorKind.Reader,
|
|
UserName = ReaderUserName,
|
|
Name = $"Feeds read on {new Uri(BaseAddress).Host}",
|
|
Summary = "It follows the communities in the feeds this PrivaPub server's people read; it never names who reads them.",
|
|
PrivateKeyPem = reader.PrivateKey,
|
|
PublicKeyPem = reader.PublicKey,
|
|
Discoverable = false,
|
|
Published = reader.CreationDate,
|
|
BaseAddress = BaseAddress
|
|
};
|
|
}
|
|
|
|
async Task<ReaderActor> LoadReaderActor(CancellationToken token)
|
|
{
|
|
var reader = await _dbEntities.ReaderActors.Sort(r => r.CreationDate, Order.Ascending).ExecuteFirstAsync(token);
|
|
if (reader != default)
|
|
return reader;
|
|
var (privateKey, publicKey) = Keys.NewKeyPair();
|
|
reader = new ReaderActor { PrivateKey = privateKey, PublicKey = publicKey };
|
|
await DB.Default.SaveAsync(reader, token);
|
|
return reader;
|
|
}
|
|
|
|
async Task<ReporterActor> LoadReporterActor(CancellationToken token)
|
|
{
|
|
var reporter = await _dbEntities.ReporterActors.Sort(r => r.CreationDate, Order.Ascending).ExecuteFirstAsync(token);
|
|
if (reporter != default)
|
|
return reporter;
|
|
var (privateKey, publicKey) = Keys.NewKeyPair();
|
|
reporter = new ReporterActor { PrivateKey = privateKey, PublicKey = publicKey };
|
|
await DB.Default.SaveAsync(reporter, token);
|
|
return reporter;
|
|
}
|
|
|
|
async Task<InstanceActor> LoadInstanceActor(CancellationToken token)
|
|
{
|
|
var instance = await _dbEntities.InstanceActors.Sort(i => i.CreationDate, Order.Ascending).ExecuteFirstAsync(token);
|
|
if (instance != default)
|
|
return instance;
|
|
var (privateKey, publicKey) = Keys.NewKeyPair();
|
|
instance = new InstanceActor { PrivateKey = privateKey, PublicKey = publicKey };
|
|
await DB.Default.SaveAsync(instance, token);
|
|
return instance;
|
|
}
|
|
|
|
public async Task<bool> IsUserNameTaken(string userName, CancellationToken token)
|
|
{
|
|
userName = userName?.ToLowerInvariant();
|
|
if (string.IsNullOrEmpty(userName) || ReservedByInstance.Contains(userName))
|
|
return true;
|
|
return await DB.Default.Find<ReservedName>().Match(r => r.Name == userName).ExecuteAnyAsync(token);
|
|
}
|
|
|
|
public async Task<bool> TryReserveUserName(string userName, LocalActorKind kind, string ownerId, CancellationToken token)
|
|
{
|
|
userName = userName?.ToLowerInvariant();
|
|
if (string.IsNullOrEmpty(userName) || ReservedByInstance.Contains(userName))
|
|
return false;
|
|
try
|
|
{
|
|
await DB.Default.SaveAsync(new ReservedName { Name = userName, OwnerKind = kind, OwnerId = ownerId }, token);
|
|
return true;
|
|
}
|
|
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public LocalActor FromAvatar(Avatar avatar) => new()
|
|
{
|
|
Id = avatar.ID,
|
|
Kind = LocalActorKind.Person,
|
|
UserName = avatar.UserName,
|
|
Name = string.IsNullOrEmpty(avatar.Name) ? avatar.UserName : avatar.Name,
|
|
Summary = avatar.Biography,
|
|
PictureURL = avatar.PictureURL,
|
|
ThumbnailURL = avatar.ThumbnailURL,
|
|
PrivateKeyPem = avatar.PrivateKey,
|
|
PublicKeyPem = avatar.PublicKey,
|
|
SigningKey = avatar.SigningKey,
|
|
Published = avatar.PublishedOn,
|
|
Fields = avatar.Fields ?? new Dictionary<string, string>(),
|
|
ManuallyApprovesFollowers = avatar.Settings?.IsLocked == true,
|
|
Discoverable = avatar.Settings?.IsDiscoverable != false,
|
|
IsBot = avatar.Settings?.IsBot == true,
|
|
Indexable = avatar.Settings?.IsIndexable == true,
|
|
Settings = avatar.Settings ?? new AvatarSettings(),
|
|
BaseAddress = BaseAddress
|
|
};
|
|
|
|
public LocalActor FromGroup(GroupEntity group) => new()
|
|
{
|
|
Id = group.ID,
|
|
Kind = LocalActorKind.Group,
|
|
UserName = group.UserName,
|
|
Name = string.IsNullOrEmpty(group.Name) ? group.UserName : group.Name,
|
|
Summary = group.Description,
|
|
PictureURL = group.PictureURL,
|
|
ThumbnailURL = group.ThumbnailURL,
|
|
PrivateKeyPem = group.PrivateKey,
|
|
PublicKeyPem = group.PublicKey,
|
|
Discoverable = group.Kind == GroupKind.Community && group.IsDiscoverable,
|
|
ManuallyApprovesFollowers = group.Kind == GroupKind.Circle || group.ManuallyApprovesMembers,
|
|
IsCircle = group.Kind == GroupKind.Circle,
|
|
PostingRestrictedToModerators = group.PostingPolicy == PostingPolicy.Moderators,
|
|
Published = group.PublishedOn,
|
|
BaseAddress = BaseAddress
|
|
};
|
|
}
|
|
|
|
public static class Keys
|
|
{
|
|
public static (string PrivateKeyPem, string PublicKeyPem) NewKeyPair()
|
|
{
|
|
using var rsa = RSA.Create(2048);
|
|
return (rsa.ExportRSAPrivateKeyPem(), rsa.ExportSubjectPublicKeyInfoPem());
|
|
}
|
|
|
|
public static string ToSubjectPublicKeyInfoPem(string publicKeyPem)
|
|
{
|
|
if (string.IsNullOrEmpty(publicKeyPem) || publicKeyPem.Contains("BEGIN PUBLIC KEY"))
|
|
return publicKeyPem;
|
|
using var rsa = RSA.Create();
|
|
rsa.ImportFromPem(publicKeyPem);
|
|
return rsa.ExportSubjectPublicKeyInfoPem();
|
|
}
|
|
}
|
|
}
|