Lemmy takes a report only from a person or a service, about one post or comment, addressed to its community, and it answered PrivaPub's Flag (the instance actor's, an Application, with no `to` and the account and posts as its object) 400. A report of a post or comment in a community on a server whose NodeInfo names Lemmy now leaves from `privapub_reports`, a Service with its own key that names nobody: one Flag per post, `to` the community (its own audience, else its thread's), with the persona's words, or the category, in `summary` and `content`, sent to the community's inbox. This is the second exception to "a server's software is for display" (owner decision 2026-10-06, `ReportService.ServiceReportTakers`). Every other server keeps the instance actor's report. An account alone is not reported to Lemmy, which takes no such report, and `forwarded` now says whether anything left. The reporter is read unsigned in SecureMode and answers WebFinger like the instance actor. Nobody follows or mentions it, the Mastodon API has no account for it, and a migration reserves its name. Checked live: Lemmy 1.0 and 0.19 keep the reports of a thread and of a comment, with alice's words, from "Reports from privapub.test", and none names her (69 checks). A sweep of every scenario with this and the next commit: 876 checks pass; Ghost's Network feed listed alice's post too late once, and Ghost passes alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
330 lines
13 KiB
C#
330 lines
13 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;
|
|
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<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";
|
|
|
|
public static bool IsServerActorName(string userName) =>
|
|
string.Equals(userName, InstanceUserName, StringComparison.OrdinalIgnoreCase) || string.Equals(userName, ReporterUserName, StringComparison.OrdinalIgnoreCase);
|
|
|
|
static readonly HashSet<string> ReservedByInstance = new(StringComparer.Ordinal)
|
|
{
|
|
InstanceUserName, ReporterUserName, "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;
|
|
|
|
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);
|
|
|
|
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);
|
|
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
|
|
};
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
}
|