Owner decision of 2026-10-06 (G-0009, FEP-400e). A persona's actor names its wall (…/graffiti, sm:wall) and, in
Smithereen's privacySettings, that its followers may write on it. A public post that is not a reply, sent with the wall
as its target by an account following the persona, is hosted: the persona is notified, it reaches the persona's and its
followers' homes, and the followers' servers and the author's are told with Add{Note}. The persona deletes it with
DELETE /api/v1/statuses/:id, which sends Remove{Note}; Smithereen deletes the post then. The wall lists the persona's
public posts that start a thread and what was written on it.
Elsewhere: an account's Add{Note} on its own wall shows the post to its followers here as on that wall (privapub.wall on
the status), and its Remove takes it away. An Add or Remove naming a collection of the account's own server PrivaPub
does not know has its document read again first, at most hourly: accounts kept before walls were read had none.
Checked live: Smithereen 40 checks (G-0009 closed); GoToSocial and Mastodon unchanged (121).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
282 lines
11 KiB
C#
282 lines
11 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 Wall => $"{Uri}/graffiti";
|
|
public bool HasWall => Kind == LocalActorKind.Person && !IsCircle && IsFederated;
|
|
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<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";
|
|
|
|
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<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);
|
|
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
|
|
};
|
|
}
|
|
|
|
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.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();
|
|
}
|
|
}
|
|
}
|