With a token for a persona, a Mastodon client can now:
- accounts: verify/update credentials (display name, note, fields, locked,
bot, discoverable, indexable, hide collections, posting defaults; the
change federates as Update{Person}), get, lookup, statuses (paged, by
visibility to the viewer), relationships, search, follow and unfollow,
follow requests (authorize and reject answer remote followers with
Accept or Reject), remove from followers. Followers and following lists
are shown to their owner only.
- statuses: post (plain text, mentions, hashtags, replies, content
warnings, the four visibilities, Idempotency-Key), get, edit (PUT),
delete returning the source for redrafting, context, history, source,
favourite, reblog and their undos, favourited_by and reblogged_by.
- timelines: home, public (local or remote), tag; favourites;
conversations; markers.
- notifications: list with types and exclude_types, get, dismiss, clear,
unread count.
- /api/v2/search, resolving a handle or a URL to an account or a post.
Media, polls, pins, bookmarks, mutes, blocks, lists, filters, trends and
push answer empty lists or a 422 saying they are not supported yet, so
clients degrade instead of failing. Public reads work without a token.
Persona settings (locked, bot, indexable, discoverable) now also shape
the ActivityPub actor.
Fixed on the way: three conditional expressions whose `default` was the
value type's, so a missing limit became 1, a missing flag became false and
an attachment without dimensions became 0x0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
246 lines
8.8 KiB
C#
246 lines
8.8 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 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 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.IsDiscoverable,
|
|
ManuallyApprovesFollowers = group.ManuallyApprovesMembers,
|
|
IsFederated = group.Kind == GroupKind.Community,
|
|
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();
|
|
}
|
|
}
|
|
}
|