Files
SocialPub/PrivaPub/Federation/Actors/LocalActorService.cs
T
thepraandClaude Opus 5.5 a5d9a89445 Communities follow FEP-1b12, circles federate to their members only
Communities:
- a post addressed to a community (to, cc or audience) is accepted
  according to its posting policy - followers, anyone, or moderators -
  and GroupDistributor announces the whole activity with `audience` to
  the community's followers, plus the object for new posts so Mastodon
  shows them; updates and deletes of community content are announced too;
- top-level posts are Pages with a name (the title, or a headline from
  the text); /flock counts members, /wardens lists moderators;
- a Mastodon client posts into a community by mentioning it, or into a
  remote group, which sets `audience`;
- an Announce of an activity from a remote group a persona follows (Lemmy)
  is followed through: the object is fetched from its own origin, kept with
  its AudienceURI, and fanned out to the group's local followers; updates
  are applied in place and deletes checked against the origin.

Circles stop being local-only: an undiscoverable Group actor whose follows
are all requests the owner approves; posts addressed to the circle and its
/flock and delivered to members' own inboxes, never announced, never
public; a remote member's post into the circle is accepted from members
only. SignedFetchAuthorizer serves circle posts and collections only to a
signed request from a member or a member server's instance actor - 404 for
anyone else. Circles never surface in search, lookups, mentions, account
ids or profile pages.

Federation:SecureMode requires a valid signature on every GET under
/peasants except the instance actor. Group forms take a posting policy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 12:30:57 +02:00

253 lines
9.2 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 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 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 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<LocalActor> FindById(LocalActorKind kind, string id, CancellationToken token);
Task<LocalActor> FindByUri(string actorUri, CancellationToken token);
Task<LocalActor> GetInstanceActor(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);
}
public class LocalActorService : ILocalActorService
{
public const string InstanceUserName = "privapub";
static readonly HashSet<string> ReservedByInstance = new(StringComparer.Ordinal)
{
InstanceUserName, "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;
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);
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<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);
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);
}
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
};
}
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,
Published = avatar.CreatedAt,
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.CreationDate,
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();
}
}
}