Files
SocialPub/PrivaPub/Federation/Actors/LocalActorService.cs
T
thepraandClaude Opus 5.5 d08fbabaa5 R7 custom emoji: the server's own, added by administrators, sent on posts, profiles and reactions
LocalEmoji, a new collection (unique shortcode whatever its case, never renamed: the shortcode is its id elsewhere),
managed on /clientapi/admin/emoji/list|insert|update|delete and kept in the staff's log (emoji.insert, .update,
.delete). Its picture goes through IMediaService.EmojiImage: PNG, GIF, WebP or JPEG up to 256 KB, a PNG within 256 px
with alpha kept (an animated GIF or WebP stays an animated GIF, with a still PNG), keep=none, shrunk until it fits
GoToSocial's 100 KiB, held by ServerUse "emoji:<shortcode>" so the janitor and the audit leave it. LocalEmojis keeps the
offered ones in memory, reloaded on every change: GET /api/v1/custom_emojis is real; local posts (words, title, warning,
poll options, each revision) and profiles keep the emoji they name; Notes, Questions, actors and EmojiReacts carry
Mastodon's Emoji tag with its id, served as an Emoji at /emojis/{shortcode} (410 once deleted; the path awaits the
owner's confirmation); a persona reacts with one (Pleroma's reactions, announcements). A restore keeps the live emoji
as it keeps the settings, and trashes server pictures nothing holds. tools/pasture/scenarios/emoji.sh joins batch A.

Verified: built, and run locally against a scratch database (uploads of every type, animation kept, metadata gone,
refusals, rendering, reactions, history, announcements, delete and revive, backup and restore, media audit). The
pasture scenario and the sweep were not run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-17 05:57:25 +02:00

389 lines
16 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; }
// the staff's moderation of a persona (completion program R6): suspended (by itself or with its banned login), it
// delivers nothing, its posts and collections answer 404 and its tokens are refused, while its actor stays as it was;
// silenced, its posts stay out of the public timelines, trends, search and the relays
public bool Suspended { get; init; }
public bool Silenced { 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>();
// the custom emoji of this server its profile named (R7); rendered as they are now (LocalEmojis.Of)
public IReadOnlyList<Models.Post.CustomEmoji> Emojis { get; init; } = Array.Empty<Models.Post.CustomEmoji>();
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"
};
// what the instance actor says of itself until the administrator describes the server (ServerSettings)
const string InstanceSummary = "The instance actor of this PrivaPub server; it signs the requests no other actor speaks for.";
readonly DbEntities _dbEntities;
readonly IOptionsMonitor<AppConfiguration> _appConfiguration;
readonly Domain.Instance.IInstanceSettings _settings;
InstanceActor _instanceActor;
ReporterActor _reporterActor;
ReaderActor _readerActor;
public LocalActorService(DbEntities dbEntities, IOptionsMonitor<AppConfiguration> appConfiguration, Domain.Instance.IInstanceSettings settings)
{
_dbEntities = dbEntities;
_appConfiguration = appConfiguration;
_settings = settings;
}
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);
// the server's name, and its short description once the administrator wrote one of their own
var settings = _settings.Current;
return new LocalActor
{
Id = instance.ID,
Kind = LocalActorKind.Application,
UserName = InstanceUserName,
Name = settings.Title,
Summary = string.IsNullOrWhiteSpace(settings.ShortDescription) || settings.ShortDescription == ServerSettings.DefaultDescription
? InstanceSummary
: settings.ShortDescription,
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>(),
Emojis = avatar.Emojis ?? new List<Models.Post.CustomEmoji>(),
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(),
Suspended = avatar.SuspendedAt.HasValue || Domain.Moderation.Restrictions.IsSuspended(avatar.ID),
Silenced = avatar.SilencedAt.HasValue,
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();
}
}
}