[backend/federation] Resolve incoming activity objects (ISH-20, ISH-12)

This commit is contained in:
Laura Hausmann 2024-02-09 13:05:07 +01:00
parent 19d6251a2a
commit 02e003afa3
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
13 changed files with 223 additions and 115 deletions

View file

@ -21,33 +21,35 @@ public static class ServiceExtensions {
//services.AddTransient<T>(); //services.AddTransient<T>();
// Scoped = instantiated per request // Scoped = instantiated per request
services.AddScoped<ActivityPub.ActivityRenderer>(); services
services.AddScoped<ActivityPub.UserRenderer>(); .AddScoped<ActivityPub.ActivityRenderer>()
services.AddScoped<ActivityPub.NoteRenderer>(); .AddScoped<ActivityPub.UserRenderer>()
services.AddScoped<ActivityPub.UserResolver>(); .AddScoped<ActivityPub.NoteRenderer>()
services.AddScoped<UserService>(); .AddScoped<ActivityPub.UserResolver>()
services.AddScoped<NoteService>(); .AddScoped<ActivityPub.ObjectResolver>()
services.AddScoped<ActivityPub.ActivityDeliverService>(); .AddScoped<ActivityPub.ActivityDeliverService>()
services.AddScoped<ActivityPub.ActivityHandlerService>(); .AddScoped<ActivityPub.FederationControlService>()
services.AddScoped<WebFingerService>(); .AddScoped<ActivityPub.ActivityHandlerService>()
services.AddScoped<ActivityPub.FederationControlService>(); .AddScoped<ActivityPub.ActivityFetcherService>()
services.AddScoped<AuthorizedFetchMiddleware>(); .AddScoped<UserService>()
services.AddScoped<AuthenticationMiddleware>(); .AddScoped<SystemUserService>()
.AddScoped<NoteService>()
//TODO: make this prettier .AddScoped<WebFingerService>()
services.AddScoped<UserRenderer>(); .AddScoped<AuthorizedFetchMiddleware>()
services.AddScoped<NoteRenderer>(); .AddScoped<AuthenticationMiddleware>()
.AddScoped<UserRenderer>()
.AddScoped<NoteRenderer>();
// Singleton = instantiated once across application lifetime // Singleton = instantiated once across application lifetime
services.AddSingleton<HttpClient>(); services
services.AddSingleton<HttpRequestService>(); .AddSingleton<HttpClient>()
services.AddSingleton<ActivityPub.ActivityFetcherService>(); .AddSingleton<HttpRequestService>()
services.AddSingleton<QueueService>(); .AddSingleton<QueueService>()
services.AddSingleton<ErrorHandlerMiddleware>(); .AddSingleton<ErrorHandlerMiddleware>()
services.AddSingleton<RequestBufferingMiddleware>(); .AddSingleton<RequestBufferingMiddleware>()
services.AddSingleton<AuthorizationMiddleware>(); .AddSingleton<AuthorizationMiddleware>()
services.AddSingleton<RequestVerificationMiddleware>(); .AddSingleton<RequestVerificationMiddleware>()
services.AddSingleton<RequestDurationMiddleware>(); .AddSingleton<RequestDurationMiddleware>();
// Hosted services = long running background tasks // Hosted services = long running background tasks
// Note: These need to be added as a singleton as well to ensure data consistency // Note: These need to be added as a singleton as well to ensure data consistency

View file

@ -8,15 +8,16 @@ using Newtonsoft.Json.Linq;
namespace Iceshrimp.Backend.Core.Federation.ActivityPub; namespace Iceshrimp.Backend.Core.Federation.ActivityPub;
//TODO: required attribute doesn't work with Newtonsoft.Json it appears public class ActivityFetcherService(HttpClient client, HttpRequestService httpRqSvc, SystemUserService systemUserSvc) {
//TODO: enforce @type values private static readonly JsonSerializerSettings JsonSerializerSettings = new();
public class ActivityFetcherService(HttpClient client, HttpRequestService httpRqSvc) {
private static readonly JsonSerializerSettings JsonSerializerSettings =
new();
//FIXME: not doing this breaks ld signatures, but doing this breaks mapping the object to datetime properties //FIXME: not doing this breaks ld signatures, but doing this breaks mapping the object to datetime properties
//new() { DateParseHandling = DateParseHandling.None }; //new() { DateParseHandling = DateParseHandling.None };
public async Task<IEnumerable<ASObject>> FetchActivityAsync(string url) {
var (actor, keypair) = await systemUserSvc.GetInstanceActorWithKeypairAsync();
return await FetchActivityAsync(url, actor, keypair);
}
public async Task<IEnumerable<ASObject>> FetchActivityAsync(string url, User actor, UserKeypair keypair) { public async Task<IEnumerable<ASObject>> FetchActivityAsync(string url, User actor, UserKeypair keypair) {
var request = httpRqSvc.GetSigned(url, ["application/activity+json"], actor, keypair); var request = httpRqSvc.GetSigned(url, ["application/activity+json"], actor, keypair);
var response = await client.SendAsync(request); var response = await client.SendAsync(request);
@ -42,8 +43,21 @@ public class ActivityFetcherService(HttpClient client, HttpRequestService httpRq
throw new GracefulException("Failed to fetch actor"); throw new GracefulException("Failed to fetch actor");
} }
public async Task<ASActor> FetchActorAsync(string uri) {
var (actor, keypair) = await systemUserSvc.GetInstanceActorWithKeypairAsync();
var activity = await FetchActivityAsync(uri, actor, keypair);
return activity.OfType<ASActor>().FirstOrDefault() ??
throw new GracefulException("Failed to fetch actor");
}
public async Task<ASNote?> FetchNoteAsync(string uri, User actor, UserKeypair keypair) { public async Task<ASNote?> FetchNoteAsync(string uri, User actor, UserKeypair keypair) {
var activity = await FetchActivityAsync(uri, actor, keypair); var activity = await FetchActivityAsync(uri, actor, keypair);
return activity.OfType<ASNote>().FirstOrDefault(); return activity.OfType<ASNote>().FirstOrDefault();
} }
public async Task<ASNote?> FetchNoteAsync(string uri) {
var (actor, keypair) = await systemUserSvc.GetInstanceActorWithKeypairAsync();
var activity = await FetchActivityAsync(uri, actor, keypair);
return activity.OfType<ASNote>().FirstOrDefault();
}
} }

View file

@ -20,7 +20,8 @@ public class ActivityHandlerService(
QueueService queueService, QueueService queueService,
ActivityRenderer activityRenderer, ActivityRenderer activityRenderer,
IOptions<Config.InstanceSection> config, IOptions<Config.InstanceSection> config,
FederationControlService federationCtrl FederationControlService federationCtrl,
ObjectResolver resolver
) { ) {
public async Task PerformActivityAsync(ASActivity activity, string? inboxUserId) { public async Task PerformActivityAsync(ASActivity activity, string? inboxUserId) {
logger.LogDebug("Processing activity: {activity}", activity.Id); logger.LogDebug("Processing activity: {activity}", activity.Id);
@ -28,6 +29,11 @@ public class ActivityHandlerService(
throw GracefulException.UnprocessableEntity("Cannot perform activity as actor 'null'"); throw GracefulException.UnprocessableEntity("Cannot perform activity as actor 'null'");
if (await federationCtrl.ShouldBlockAsync(activity.Actor.Id)) if (await federationCtrl.ShouldBlockAsync(activity.Actor.Id))
throw GracefulException.UnprocessableEntity("Instance is blocked"); throw GracefulException.UnprocessableEntity("Instance is blocked");
if (activity.Object == null)
throw GracefulException.UnprocessableEntity("Activity object is null");
if (activity.Object.IsUnresolved)
activity.Object = await resolver.ResolveObject(activity.Object) ??
throw GracefulException.UnprocessableEntity("Failed to resolve activity object");
//TODO: validate inboxUserId //TODO: validate inboxUserId
@ -40,11 +46,22 @@ public class ActivityHandlerService(
return; return;
} }
case ASDelete: { case ASDelete: {
//TODO: handle user deletes if (activity.Object is not ASTombstone tombstone)
if (activity.Object is not ASNote note)
throw GracefulException.UnprocessableEntity("Delete activity object is invalid"); throw GracefulException.UnprocessableEntity("Delete activity object is invalid");
await noteSvc.DeleteNoteAsync(note, activity.Actor); if (await db.Notes.AnyAsync(p => p.Uri == tombstone.Id)) {
return; await noteSvc.DeleteNoteAsync(tombstone, activity.Actor);
return;
}
if (await db.Users.AnyAsync(p => p.Uri == tombstone.Id)) {
if (tombstone.Id != activity.Actor.Id)
throw GracefulException.UnprocessableEntity("Refusing to delete user: actor doesn't match");
//TODO: handle user deletes
throw new NotImplementedException("User deletes aren't supported yet");
}
throw GracefulException.UnprocessableEntity("Delete activity object is unknown or invalid");
} }
case ASFollow: { case ASFollow: {
if (activity.Object is not { } obj) if (activity.Object is not { } obj)
@ -73,7 +90,7 @@ public class ActivityHandlerService(
case ASUndo: { case ASUndo: {
//TODO: what other types of undo objects are there? //TODO: what other types of undo objects are there?
if (activity.Object is not ASActivity { Type: ASActivity.Types.Follow, Object: not null } undoActivity) if (activity.Object is not ASActivity { Type: ASActivity.Types.Follow, Object: not null } undoActivity)
throw new NotImplementedException("Unsupported undo operation"); throw new NotImplementedException("Undo activity object is invalid");
await UnfollowAsync(undoActivity.Object, activity.Actor); await UnfollowAsync(undoActivity.Object, activity.Actor);
return; return;
} }

View file

@ -0,0 +1,39 @@
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
using Microsoft.EntityFrameworkCore;
namespace Iceshrimp.Backend.Core.Federation.ActivityPub;
public class ObjectResolver(
ILogger<ObjectResolver> logger,
ActivityFetcherService fetchSvc,
DatabaseContext db,
FederationControlService federationCtrl
) {
public async Task<ASObject?> ResolveObject(ASIdObject idObj) {
if (idObj is ASObject obj) return obj;
if (idObj.Id == null) {
logger.LogDebug("Refusing to resolve object with null id property");
return null;
}
if (await federationCtrl.ShouldBlockAsync(idObj.Id)) {
logger.LogDebug("Instance is blocked");
return null;
}
if (await db.Notes.AnyAsync(p => p.Uri == idObj.Id))
return new ASNote { Id = idObj.Id };
if (await db.Users.AnyAsync(p => p.Uri == idObj.Id))
return new ASActor { Id = idObj.Id };
try {
var result = await fetchSvc.FetchActivityAsync(idObj.Id);
return result.FirstOrDefault();
}
catch (Exception e) {
logger.LogDebug("Failed to resolve object {id}: {error}", idObj.Id, e);
return null;
}
}
}

View file

@ -13,7 +13,7 @@ public class ASActivity : ASObject {
[JC(typeof(ASObjectConverter))] [JC(typeof(ASObjectConverter))]
public ASObject? Object { get; set; } public ASObject? Object { get; set; }
public static class Types { public new static class Types {
private const string Ns = Constants.ActivityStreamsNs; private const string Ns = Constants.ActivityStreamsNs;
public const string Create = $"{Ns}#Create"; public const string Create = $"{Ns}#Create";

View file

@ -134,7 +134,7 @@ public class ASActor : ASObject {
}; };
} }
public static class Types { public new static class Types {
private const string Ns = Constants.ActivityStreamsNs; private const string Ns = Constants.ActivityStreamsNs;
public const string Application = $"{Ns}#Application"; public const string Application = $"{Ns}#Application";

View file

@ -2,9 +2,8 @@ using J = Newtonsoft.Json.JsonPropertyAttribute;
namespace Iceshrimp.Backend.Core.Federation.ActivityStreams.Types; namespace Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
//TODO: handle object | link correctly (we don't need to resolve it if it's already the full object)
public class ASIdObject() { public class ASIdObject() {
public ASIdObject(string id) : this() { public ASIdObject(string? id) : this() {
Id = id; Id = id;
} }

View file

@ -55,7 +55,7 @@ public class ASNote : ASObject {
return Note.NoteVisibility.Specified; return Note.NoteVisibility.Specified;
} }
public static class Types { public new static class Types {
private const string Ns = Constants.ActivityStreamsNs; private const string Ns = Constants.ActivityStreamsNs;
public const string Note = $"{Ns}#Note"; public const string Note = $"{Ns}#Note";

View file

@ -1,3 +1,4 @@
using Iceshrimp.Backend.Core.Configuration;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using J = Newtonsoft.Json.JsonPropertyAttribute; using J = Newtonsoft.Json.JsonPropertyAttribute;
@ -8,11 +9,13 @@ namespace Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
public class ASObject : ASIdObject { public class ASObject : ASIdObject {
[J("@id")] [JR] public new required string Id { get; set; } [J("@id")] [JR] public new required string Id { get; set; }
[J("@type")] [J("@type")]
[JC(typeof(LDTypeConverter))] [JC(typeof(LDTypeConverter))]
public string? Type { get; set; } public string? Type { get; set; }
public bool IsUnresolved => GetType() == typeof(ASObject) && Type == null;
//FIXME: don't recurse creates and co //FIXME: don't recurse creates and co
public static ASObject? Deserialize(JToken token) { public static ASObject? Deserialize(JToken token) {
return token.Type switch { return token.Type switch {
@ -23,6 +26,7 @@ public class ASObject : ASIdObject {
ASActor.Types.Organization => token.ToObject<ASActor>(), ASActor.Types.Organization => token.ToObject<ASActor>(),
ASActor.Types.Application => token.ToObject<ASActor>(), ASActor.Types.Application => token.ToObject<ASActor>(),
ASNote.Types.Note => token.ToObject<ASNote>(), ASNote.Types.Note => token.ToObject<ASNote>(),
Types.Tombstone => token.ToObject<ASTombstone>(),
ASActivity.Types.Create => token.ToObject<ASCreate>(), ASActivity.Types.Create => token.ToObject<ASCreate>(),
ASActivity.Types.Delete => token.ToObject<ASDelete>(), ASActivity.Types.Delete => token.ToObject<ASDelete>(),
ASActivity.Types.Follow => token.ToObject<ASFollow>(), ASActivity.Types.Follow => token.ToObject<ASFollow>(),
@ -33,13 +37,24 @@ public class ASObject : ASIdObject {
ASActivity.Types.Like => token.ToObject<ASActivity>(), ASActivity.Types.Like => token.ToObject<ASActivity>(),
_ => token.ToObject<ASObject>() _ => token.ToObject<ASObject>()
}, },
JTokenType.Array => Deserialize(token.First()), JTokenType.Array => Deserialize(token.First()),
JTokenType.String => new ASObject { Id = token.Value<string>() ?? "" }, JTokenType.String => new ASObject {
_ => throw new ArgumentOutOfRangeException() Id = token.Value<string>() ??
throw new Exception("Encountered JTokenType.String with Value<string> null")
},
_ => throw new Exception($"Encountered JTokenType {token.Type}, which is not valid at this point")
}; };
} }
public static class Types {
private const string Ns = Constants.ActivityStreamsNs;
public const string Tombstone = $"{Ns}#Tombstone";
}
} }
public class ASTombstone : ASObject;
public sealed class LDTypeConverter : ASSerializer.ListSingleObjectConverter<string>; public sealed class LDTypeConverter : ASSerializer.ListSingleObjectConverter<string>;
internal sealed class ASObjectConverter : JsonConverter { internal sealed class ASObjectConverter : JsonConverter {

View file

@ -14,7 +14,7 @@ public class AuthorizedFetchMiddleware(
IOptionsSnapshot<Config.SecuritySection> config, IOptionsSnapshot<Config.SecuritySection> config,
DatabaseContext db, DatabaseContext db,
ActivityPub.UserResolver userResolver, ActivityPub.UserResolver userResolver,
UserService userSvc, SystemUserService systemUserSvc,
ActivityPub.FederationControlService fedCtrlSvc, ActivityPub.FederationControlService fedCtrlSvc,
ILogger<AuthorizedFetchMiddleware> logger) : IMiddleware { ILogger<AuthorizedFetchMiddleware> logger) : IMiddleware {
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) { public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) {
@ -24,7 +24,7 @@ public class AuthorizedFetchMiddleware(
var request = ctx.Request; var request = ctx.Request;
//TODO: cache this somewhere //TODO: cache this somewhere
var instanceActorUri = $"/users/{(await userSvc.GetInstanceActorAsync()).Id}"; var instanceActorUri = $"/users/{(await systemUserSvc.GetInstanceActorAsync()).Id}";
if (ctx.Request.Path.Value == instanceActorUri) { if (ctx.Request.Path.Value == instanceActorUri) {
await next(ctx); await next(ctx);
return; return;

View file

@ -60,7 +60,7 @@ public class NoteService(
return note; return note;
} }
public async Task DeleteNoteAsync(ASNote note, ASActor actor) { public async Task DeleteNoteAsync(ASTombstone note, ASActor actor) {
// ReSharper disable once EntityFramework.NPlusOne.IncompleteDataQuery (it doesn't know about IncludeCommonProperties()) // ReSharper disable once EntityFramework.NPlusOne.IncompleteDataQuery (it doesn't know about IncludeCommonProperties())
var dbNote = await db.Notes.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Uri == note.Id); var dbNote = await db.Notes.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Uri == note.Id);
if (dbNote == null) { if (dbNote == null) {
@ -157,9 +157,7 @@ public class NoteService(
if (note != null) return note; if (note != null) return note;
//TODO: should we fall back to a regular user's keypair if fetching with instance actor fails & a local user is following the actor? //TODO: should we fall back to a regular user's keypair if fetching with instance actor fails & a local user is following the actor?
var instanceActor = await userSvc.GetInstanceActorAsync(); var fetchedNote = await fetchSvc.FetchNoteAsync(uri);
var instanceActorKeypair = await db.UserKeypairs.FirstAsync(p => p.User == instanceActor);
var fetchedNote = await fetchSvc.FetchNoteAsync(uri, instanceActor, instanceActorKeypair);
if (fetchedNote?.AttributedTo is not [{ Id: not null } attrTo]) { if (fetchedNote?.AttributedTo is not [{ Id: not null } attrTo]) {
logger.LogDebug("Invalid Note.AttributedTo, skipping"); logger.LogDebug("Invalid Note.AttributedTo, skipping");
return null; return null;
@ -171,7 +169,7 @@ public class NoteService(
} }
//TODO: we don't need to fetch the actor every time, we can use userResolver here //TODO: we don't need to fetch the actor every time, we can use userResolver here
var actor = await fetchSvc.FetchActorAsync(attrTo.Id, instanceActor, instanceActorKeypair); var actor = await fetchSvc.FetchActorAsync(attrTo.Id);
try { try {
return await ProcessNoteAsync(fetchedNote, actor); return await ProcessNoteAsync(fetchedNote, actor);

View file

@ -0,0 +1,84 @@
using System.Security.Cryptography;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Helpers;
using Iceshrimp.Backend.Core.Middleware;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed;
namespace Iceshrimp.Backend.Core.Services;
public class SystemUserService(ILogger<SystemUserService> logger, DatabaseContext db, IDistributedCache cache) {
public async Task<User> GetInstanceActorAsync() {
return await GetOrCreateSystemUserAsync("instance.actor");
}
public async Task<User> GetRelayActorAsync() {
return await GetOrCreateSystemUserAsync("relay.actor");
}
public async Task<(User user, UserKeypair keypair)> GetInstanceActorWithKeypairAsync() {
return await GetOrCreateSystemUserAndKeypairAsync("instance.actor");
}
public async Task<(User user, UserKeypair keypair)> GetRelayActorWithKeypairAsync() {
return await GetOrCreateSystemUserAndKeypairAsync("relay.actor");
}
private async Task<(User user, UserKeypair keypair)> GetOrCreateSystemUserAndKeypairAsync(string username) {
var user = await GetOrCreateSystemUserAsync(username);
var keypair = await db.UserKeypairs.FirstAsync(p => p.User == user); //TODO: cache this in redis as well
return (user, keypair);
}
private async Task<User> GetOrCreateSystemUserAsync(string username) {
return await cache.FetchAsync($"systemUser:{username}", TimeSpan.FromHours(24), async () => {
logger.LogTrace("GetOrCreateSystemUser delegate method called for user {username}", username);
return await db.Users.FirstOrDefaultAsync(p => p.UsernameLower == username &&
p.Host == null) ??
await CreateSystemUserAsync(username);
});
}
private async Task<User> CreateSystemUserAsync(string username) {
if (await db.Users.AnyAsync(p => p.UsernameLower == username.ToLowerInvariant() && p.Host == null))
throw new GracefulException($"System user {username} already exists");
var keypair = RSA.Create(4096);
var user = new User {
Id = IdHelpers.GenerateSlowflakeId(),
CreatedAt = DateTime.UtcNow,
Username = username,
UsernameLower = username.ToLowerInvariant(),
Host = null,
IsAdmin = false,
IsLocked = true,
IsExplorable = false,
IsBot = true
};
var userKeypair = new UserKeypair {
UserId = user.Id,
PrivateKey = keypair.ExportPkcs8PrivateKeyPem(),
PublicKey = keypair.ExportSubjectPublicKeyInfoPem()
};
var userProfile = new UserProfile {
UserId = user.Id,
AutoAcceptFollowed = false,
Password = null
};
var usedUsername = new UsedUsername {
CreatedAt = DateTime.UtcNow,
Username = username.ToLowerInvariant()
};
await db.AddRangeAsync(user, userKeypair, userProfile, usedUsername);
await db.SaveChangesAsync();
return user;
}
}

View file

@ -52,9 +52,7 @@ public class UserService(
public async Task<User> CreateUserAsync(string uri, string acct) { public async Task<User> CreateUserAsync(string uri, string acct) {
logger.LogDebug("Creating user {acct} with uri {uri}", acct, uri); logger.LogDebug("Creating user {acct} with uri {uri}", acct, uri);
var instanceActor = await GetInstanceActorAsync(); var actor = await fetchSvc.FetchActorAsync(uri);
var instanceActorKeypair = await db.UserKeypairs.FirstAsync(p => p.User == instanceActor);
var actor = await fetchSvc.FetchActorAsync(uri, instanceActor, instanceActorKeypair);
logger.LogDebug("Got actor: {url}", actor.Url); logger.LogDebug("Got actor: {url}", actor.Url);
actor.Normalize(uri, acct); actor.Normalize(uri, acct);
@ -63,7 +61,7 @@ public class UserService(
Id = IdHelpers.GenerateSlowflakeId(), Id = IdHelpers.GenerateSlowflakeId(),
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
LastFetchedAt = DateTime.UtcNow, LastFetchedAt = DateTime.UtcNow,
DisplayName = actor.DisplayName, DisplayName = actor.DisplayName,
IsLocked = actor.IsLocked ?? false, IsLocked = actor.IsLocked ?? false,
IsBot = actor.IsBot, IsBot = actor.IsBot,
Username = actor.Username!, Username = actor.Username!,
@ -153,62 +151,4 @@ public class UserService(
return user; return user;
} }
public async Task<User> GetInstanceActorAsync() {
return await GetOrCreateSystemUserAsync("instance.actor");
}
public async Task<User> GetRelayActorAsync() {
return await GetOrCreateSystemUserAsync("relay.actor");
}
private async Task<User> GetOrCreateSystemUserAsync(string username) {
return await cache.FetchAsync($"systemUser:{username}", TimeSpan.FromHours(24), async () => {
logger.LogTrace("GetOrCreateSystemUser delegate method called for user {username}", username);
return await db.Users.FirstOrDefaultAsync(p => p.UsernameLower == username &&
p.Host == null) ??
await CreateSystemUserAsync(username);
});
}
private async Task<User> CreateSystemUserAsync(string username) {
if (await db.Users.AnyAsync(p => p.UsernameLower == username.ToLowerInvariant() && p.Host == null))
throw new GracefulException($"System user {username} already exists");
var keypair = RSA.Create(4096);
var user = new User {
Id = IdHelpers.GenerateSlowflakeId(),
CreatedAt = DateTime.UtcNow,
Username = username,
UsernameLower = username.ToLowerInvariant(),
Host = null,
IsAdmin = false,
IsLocked = true,
IsExplorable = false,
IsBot = true
};
var userKeypair = new UserKeypair {
UserId = user.Id,
PrivateKey = keypair.ExportPkcs8PrivateKeyPem(),
PublicKey = keypair.ExportSubjectPublicKeyInfoPem()
};
var userProfile = new UserProfile {
UserId = user.Id,
AutoAcceptFollowed = false,
Password = null
};
var usedUsername = new UsedUsername {
CreatedAt = DateTime.UtcNow,
Username = username.ToLowerInvariant()
};
await db.AddRangeAsync(user, userKeypair, userProfile, usedUsername);
await db.SaveChangesAsync();
return user;
}
} }