net10, the refactor finished, and federation that works
The tree had not compiled since its first commit: Group, DmGroup and
IGroupUsersService were referenced and never written, an IDE rename had
turned the user-settings DTO into the ViewAvatarServer enum, and the settings
were saved as an entity they no longer were.
Built now:
- Group (an ActivityPub Group actor with its own keys, members, invitation
code and optional password) and DmGroup (a conversation), with
/clientapi/group/{list,insert,update,join,leave,approve}.
- Posts and DMs: /clientapi/post/{list,insert,delete}, /clientapi/dm/{list,insert};
DM recipients are local usernames or user@host handles resolved by WebFinger.
- Invitation sign-up and login against the group's invitation code, checking
the password before any account is created.
- Federation: WebFinger, NodeInfo 2.0, actors at /peasants/{name} (Person,
Group, and an Application instance actor) with SPKI keys, draft-cavage
RSA-SHA256 HTTP signatures both ways, an inbox handling Follow (+Accept),
Undo, Create, Delete and Update, an outbox, notes at /posts/{id}, and a
persisted, retried, signed delivery queue. A post to a group is announced
by the group to its followers (FEP-1b12).
- The unused, broken typed ActivityPub models are replaced by a renderer;
NSign's HMAC setup, which could not federate, is gone.
Upgrade: net10.0, MongoDB.Entities 25.1 (instance DB API, Standard GUIDs),
Swashbuckle 10 / OpenApi 2, Serilog.AspNetCore 10, MailKit 4.18,
PasswordGenerator 3. The JWT keys are 64 bytes (IdentityModel 8 refuses
shorter for HS512). Production runs its own mongod on 127.0.0.1:27022, as
Sintopia's apps do, and deploys to privapub.thepra.dev from the build runner.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
1 parent
56c5396106
commit
075c22228a
78 files changed
+3419
-1091
No files matched your search
@@ -0,0 +1,201 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Security.Cryptography;
|
||||
|
||||
using GroupEntity = PrivaPub.Models.Group.Group;
|
||||
|
||||
namespace PrivaPub.Services.Federation
|
||||
{
|
||||
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 DateTime Published { get; init; }
|
||||
public string BaseAddress { get; init; }
|
||||
|
||||
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}/followers";
|
||||
public string Following => $"{Uri}/following";
|
||||
public string SharedInbox => $"{BaseAddress}/human-centipede";
|
||||
public string Domain => new Uri(BaseAddress).Authority;
|
||||
public string Handle => $"{UserName}@{Domain}";
|
||||
public string PostUri(string postId) => $"{Uri}/posts/{postId}";
|
||||
public string ActivityUri(string activityId) => $"{Uri}/activities/{activityId}";
|
||||
}
|
||||
|
||||
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);
|
||||
LocalActor FromAvatar(Avatar avatar);
|
||||
LocalActor FromGroup(GroupEntity group);
|
||||
}
|
||||
|
||||
public class LocalActorService : ILocalActorService
|
||||
{
|
||||
public const string InstanceUserName = "privapub";
|
||||
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly IOptionsMonitor<AppConfiguration> _appConfiguration;
|
||||
|
||||
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 = await _dbEntities.InstanceActors.ExecuteFirstAsync(token);
|
||||
if (instance == default)
|
||||
{
|
||||
var (privateKey, publicKey) = Keys.NewKeyPair();
|
||||
instance = new InstanceActor { PrivateKey = privateKey, PublicKey = publicKey };
|
||||
await DB.Default.SaveAsync(instance, 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<bool> IsUserNameTaken(string userName, CancellationToken token)
|
||||
{
|
||||
userName = userName?.ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(userName) || userName == InstanceUserName)
|
||||
return true;
|
||||
if (await _dbEntities.Avatars.Match(a => a.UserName == userName).ExecuteAnyAsync(token))
|
||||
return true;
|
||||
return await _dbEntities.Groups.Match(g => g.UserName == userName).ExecuteAnyAsync(token);
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user