From f19a414b2766856f1df0061d7c947e8b3f0619cc Mon Sep 17 00:00:00 2001 From: Laura Hausmann Date: Thu, 24 Oct 2024 01:03:01 +0200 Subject: [PATCH] [backend/federation] Refactor UserResolver (ISH-548) --- .../Controllers/Mastodon/AccountController.cs | 6 +- .../Controllers/Mastodon/SearchController.cs | 32 ++- .../Controllers/Web/MigrationController.cs | 5 +- .../Controllers/Web/SearchController.cs | 5 +- .../Core/Database/Tables/User.cs | 3 + .../ActivityPub/ActivityHandlerService.cs | 19 +- .../Federation/ActivityPub/UserResolver.cs | 197 +++++++++--------- Iceshrimp.Backend/Core/Helpers/Result.cs | 83 ++++++++ .../Middleware/AuthorizedFetchMiddleware.cs | 3 +- .../Middleware/InboxValidationMiddleware.cs | 16 +- .../Core/Services/NoteService.cs | 42 ++-- .../Services/UserProfileMentionsResolver.cs | 15 +- .../Core/Services/UserService.cs | 27 ++- 13 files changed, 276 insertions(+), 177 deletions(-) create mode 100644 Iceshrimp.Backend/Core/Helpers/Result.cs diff --git a/Iceshrimp.Backend/Controllers/Mastodon/AccountController.cs b/Iceshrimp.Backend/Controllers/Mastodon/AccountController.cs index 35e4e42e..4fb273af 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/AccountController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/AccountController.cs @@ -17,6 +17,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; +using static Iceshrimp.Backend.Core.Federation.ActivityPub.UserResolver; namespace Iceshrimp.Backend.Controllers.Mastodon; @@ -604,8 +605,11 @@ public class AccountController( [ProducesErrors(HttpStatusCode.NotFound)] public async Task LookupUser([FromQuery] string acct) { + const ResolveFlags flags = + ResolveFlags.Acct | ResolveFlags.Uri | ResolveFlags.MatchUrl | ResolveFlags.OnlyExisting; + var localUser = HttpContext.GetUser(); - var user = await userResolver.LookupAsync(acct) ?? throw GracefulException.RecordNotFound(); + var user = await userResolver.ResolveOrNullAsync(acct, flags) ?? throw GracefulException.RecordNotFound(); user = await userResolver.GetUpdatedUser(user); return await userRenderer.RenderAsync(user, localUser); } diff --git a/Iceshrimp.Backend/Controllers/Mastodon/SearchController.cs b/Iceshrimp.Backend/Controllers/Mastodon/SearchController.cs index b9f19e26..6b4b9096 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/SearchController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/SearchController.cs @@ -18,6 +18,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; +using static Iceshrimp.Backend.Core.Federation.ActivityPub.UserResolver; namespace Iceshrimp.Backend.Controllers.Mastodon; @@ -84,17 +85,15 @@ public class SearchController( if (search.Query!.StartsWith("https://") || search.Query.StartsWith("http://")) { if (pagination.Offset is not null and not 0) return []; - try - { - var result = await userResolver.ResolveAsync(search.Query) - .ContinueWithResult(userResolver.GetUpdatedUser); - return [await userRenderer.RenderAsync(result, user)]; - } - catch + var result = await userResolver + .ResolveOrNullAsync(search.Query, ResolveFlags.Uri | ResolveFlags.MatchUrl); + + return result switch { - return []; - } + not null => [await userRenderer.RenderAsync(await userResolver.GetUpdatedUser(result), user)], + _ => [] + }; } var regex = new Regex @@ -118,17 +117,12 @@ public class SearchController( var username = match.Groups["user"].Value; var host = match.Groups["host"].Value; - try + var result = await userResolver.ResolveOrNullAsync(GetQuery(username, host), ResolveFlags.Acct); + return result switch { - var result = await userResolver.ResolveAsync($"@{username}@{host}") - .ContinueWithResult(userResolver.GetUpdatedUser); - - return [await userRenderer.RenderAsync(result, user)]; - } - catch - { - return []; - } + not null => [await userRenderer.RenderAsync(await userResolver.GetUpdatedUser(result), user)], + _ => [] + }; } } } diff --git a/Iceshrimp.Backend/Controllers/Web/MigrationController.cs b/Iceshrimp.Backend/Controllers/Web/MigrationController.cs index ad33f2de..9f5401eb 100644 --- a/Iceshrimp.Backend/Controllers/Web/MigrationController.cs +++ b/Iceshrimp.Backend/Controllers/Web/MigrationController.cs @@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; +using static Iceshrimp.Backend.Core.Federation.ActivityPub.UserResolver; namespace Iceshrimp.Backend.Controllers.Web; @@ -50,7 +51,7 @@ public class MigrationController( if (rq.UserId is not null) aliasUser = await db.Users.IncludeCommonProperties().Where(p => p.Id == rq.UserId).FirstOrDefaultAsync(); if (rq.UserUri is not null) - aliasUser ??= await userResolver.ResolveAsyncOrNull(rq.UserUri); + aliasUser ??= await userResolver.ResolveOrNullAsync(rq.UserUri, EnforceUriFlags); if (aliasUser is null) throw GracefulException.NotFound("Alias user not found or not specified"); @@ -88,7 +89,7 @@ public class MigrationController( if (rq.UserId is not null) targetUser = await db.Users.IncludeCommonProperties().Where(p => p.Id == rq.UserId).FirstOrDefaultAsync(); if (rq.UserUri is not null) - targetUser ??= await userResolver.ResolveAsyncOrNull(rq.UserUri); + targetUser ??= await userResolver.ResolveOrNullAsync(rq.UserUri, EnforceUriFlags); if (targetUser is null) throw GracefulException.NotFound("Target user not found"); diff --git a/Iceshrimp.Backend/Controllers/Web/SearchController.cs b/Iceshrimp.Backend/Controllers/Web/SearchController.cs index 4167a798..bbab7981 100644 --- a/Iceshrimp.Backend/Controllers/Web/SearchController.cs +++ b/Iceshrimp.Backend/Controllers/Web/SearchController.cs @@ -15,6 +15,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; +using static Iceshrimp.Backend.Core.Federation.ActivityPub.UserResolver; namespace Iceshrimp.Backend.Controllers.Web; @@ -87,7 +88,7 @@ public class SearchController( if (target.StartsWith('@') || target.StartsWith(userPrefixAlt)) { - var hit = await userResolver.ResolveAsyncOrNull(target); + var hit = await userResolver.ResolveOrNullAsync(target, SearchFlags); if (hit != null) return new RedirectResponse { TargetUrl = $"/users/{hit.Id}" }; throw GracefulException.NotFound("No result found"); } @@ -125,7 +126,7 @@ public class SearchController( noteHit = await noteSvc.ResolveNoteAsync(target); if (noteHit != null) return new RedirectResponse { TargetUrl = $"/notes/{noteHit.Id}" }; - userHit = await userResolver.ResolveAsyncOrNull(target); + userHit = await userResolver.ResolveOrNullAsync(target, ResolveFlags.Uri | ResolveFlags.MatchUrl); if (userHit != null) return new RedirectResponse { TargetUrl = $"/users/{userHit.Id}" }; throw GracefulException.NotFound("No result found"); diff --git a/Iceshrimp.Backend/Core/Database/Tables/User.cs b/Iceshrimp.Backend/Core/Database/Tables/User.cs index 1e430ce0..3b3bd025 100644 --- a/Iceshrimp.Backend/Core/Database/Tables/User.cs +++ b/Iceshrimp.Backend/Core/Database/Tables/User.cs @@ -616,6 +616,7 @@ public class User : IEntity public string GetPublicUrl(Config.InstanceSection config) => GetPublicUrl(config.WebDomain); public string GetPublicUri(Config.InstanceSection config) => GetPublicUri(config.WebDomain); + public string GetUriOrPublicUri(Config.InstanceSection config) => GetUriOrPublicUri(config.WebDomain); public string GetIdenticonUrl(Config.InstanceSection config) => GetIdenticonUrl(config.WebDomain); public string GetIdenticonUrlPng(Config.InstanceSection config) => GetIdenticonUrl(config.WebDomain) + ".png"; @@ -623,6 +624,8 @@ public class User : IEntity ? $"https://{webDomain}/users/{Id}" : throw new Exception("Cannot access PublicUri for remote user"); + public string GetUriOrPublicUri(string webDomain) => Uri ?? GetPublicUri(webDomain); + public string GetPublicUrl(string webDomain) => Host == null ? $"https://{webDomain}{PublicUrlPath}" : throw new Exception("Cannot access PublicUrl for remote user"); diff --git a/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityHandlerService.cs b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityHandlerService.cs index 43c8c29b..bcce445d 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityHandlerService.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityHandlerService.cs @@ -9,6 +9,7 @@ using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Services; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; +using static Iceshrimp.Backend.Core.Federation.ActivityPub.UserResolver; namespace Iceshrimp.Backend.Core.Federation.ActivityPub; @@ -40,7 +41,7 @@ public class ActivityHandlerService( if (activity.Object == null && activity is not ASBite) throw GracefulException.UnprocessableEntity("Activity object is null"); - var resolvedActor = await userResolver.ResolveAsync(activity.Actor.Id); + var resolvedActor = await userResolver.ResolveAsync(activity.Actor.Id, EnforceUriFlags); if (authenticatedUserId == null) throw GracefulException.UnprocessableEntity("Refusing to process activity without authenticatedUserId"); @@ -160,7 +161,7 @@ public class ActivityHandlerService( if (activity.Object is not ASActor obj) throw GracefulException.UnprocessableEntity("Follow activity object is invalid"); - var followee = await userResolver.ResolveAsync(obj.Id); + var followee = await userResolver.ResolveAsync(obj.Id, EnforceUriFlags); if (followee.IsRemoteUser) throw GracefulException.UnprocessableEntity("Cannot process follow for remote followee"); @@ -245,7 +246,7 @@ public class ActivityHandlerService( if (follow is not { Actor: not null }) throw GracefulException.UnprocessableEntity("Refusing to reject object with invalid follow object"); - var resolvedFollower = await userResolver.ResolveAsync(follow.Actor.Id); + var resolvedFollower = await userResolver.ResolveAsync(follow.Actor.Id, EnforceUriFlags); if (resolvedFollower is not { IsLocalUser: true }) throw GracefulException.UnprocessableEntity("Refusing to reject remote follow"); if (resolvedActor.Uri == null) @@ -378,7 +379,7 @@ public class ActivityHandlerService( Uri = activity.Id, User = resolvedActor, UserHost = resolvedActor.Host, - TargetUser = await userResolver.ResolveAsync(targetActor.Id) + TargetUser = await userResolver.ResolveAsync(targetActor.Id, EnforceUriFlags) }, ASNote targetNote => new Bite { @@ -408,7 +409,7 @@ public class ActivityHandlerService( Uri = activity.Id, User = resolvedActor, UserHost = resolvedActor.Host, - TargetUser = await userResolver.ResolveAsync(activity.To.Id) + TargetUser = await userResolver.ResolveAsync(activity.To.Id, EnforceUriFlags) }, _ => throw GracefulException.UnprocessableEntity($"Invalid bite target {target.Id} with type {target.Type}") @@ -499,7 +500,7 @@ public class ActivityHandlerService( return; } - var resolvedBlockee = await userResolver.ResolveAsync(blockee.Id, true); + var resolvedBlockee = await userResolver.ResolveAsync(blockee.Id, EnforceUriFlags | ResolveFlags.OnlyExisting); if (resolvedBlockee == null) throw GracefulException.UnprocessableEntity("Unknown block target"); if (resolvedBlockee.IsRemoteUser) @@ -510,7 +511,7 @@ public class ActivityHandlerService( private async Task HandleMove(ASMove activity, User resolvedActor) { if (activity.Target.Id is null) throw GracefulException.UnprocessableEntity("Move target must have an ID"); - var target = await userResolver.ResolveAsync(activity.Target.Id); + var target = await userResolver.ResolveAsync(activity.Target.Id, EnforceUriFlags); var source = await userSvc.UpdateUserAsync(resolvedActor, force: true); target = await userSvc.UpdateUserAsync(target, force: true); @@ -529,7 +530,7 @@ public class ActivityHandlerService( private async Task UnfollowAsync(ASActor followeeActor, User follower) { //TODO: send reject? or do we not want to copy that part of the old ap core - var followee = await userResolver.ResolveAsync(followeeActor.Id); + var followee = await userResolver.ResolveAsync(followeeActor.Id, EnforceUriFlags); await db.FollowRequests.Where(p => p.Follower == follower && p.Followee == followee).ExecuteDeleteAsync(); @@ -567,7 +568,7 @@ public class ActivityHandlerService( private async Task UnblockAsync(User blocker, ASActor blockee) { - var resolvedBlockee = await userResolver.ResolveAsync(blockee.Id, true); + var resolvedBlockee = await userResolver.ResolveAsync(blockee.Id, EnforceUriFlags | ResolveFlags.OnlyExisting); if (resolvedBlockee == null) throw GracefulException.UnprocessableEntity("Unknown block target"); if (resolvedBlockee.IsRemoteUser) diff --git a/Iceshrimp.Backend/Core/Federation/ActivityPub/UserResolver.cs b/Iceshrimp.Backend/Core/Federation/ActivityPub/UserResolver.cs index 4b0b72ef..5228dfd3 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityPub/UserResolver.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityPub/UserResolver.cs @@ -1,8 +1,10 @@ +using System.Net; using AsyncKeyedLock; using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Federation.WebFinger; +using Iceshrimp.Backend.Core.Helpers; using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Services; using Microsoft.EntityFrameworkCore; @@ -205,136 +207,143 @@ public class UserResolver( return query; } - public async Task ResolveAsync(string username, string? host) + public static string GetQuery(string username, string? host) => host != null + ? $"acct:{username}@{host}" + : $"acct:{username}"; + + [Flags] + public enum ResolveFlags { - return host != null ? await ResolveAsync($"acct:{username}@{host}") : await ResolveAsync($"acct:{username}"); + /// + /// No flags. Do not use. + /// + None = 0, + + /// + /// Whether to allow http/s: queries. + /// + Uri = 1, + + /// + /// Whether to allow acct: queries. + /// + Acct = 2, + + /// + /// Whether to match user urls in addition to user uris. Does nothing if the Uri flag is not set. + /// + MatchUrl = 4, + + /// + /// Whether to enforce that the returned object has the same URL as the query. Does nothing if the Uri flag is not set. + /// + EnforceUri = 8, + + /// + /// Return early if no matching user was found in the database + /// + OnlyExisting = 16 } - public async Task LookupAsync(string query) - { - query = NormalizeQuery(query); - return await userSvc.GetUserFromQueryAsync(query); - } + public const ResolveFlags EnforceUriFlags = ResolveFlags.Uri | ResolveFlags.EnforceUri; + public const ResolveFlags SearchFlags = ResolveFlags.Uri | ResolveFlags.MatchUrl | ResolveFlags.Acct; - public async Task ResolveAsync(string query) + /// + /// Resolves a local or remote user. + /// + /// The query to resolve. Can be an acct (with acct: or @ prefix), + /// The options to use for the resolve process. + /// The user in question. + private async Task> ResolveInternalAsync(string query, ResolveFlags flags) { query = NormalizeQuery(query); - // Before we begin, let's skip local note urls + // Before we begin, validate method parameters + if (flags == 0) + throw new Exception("ResolveFlags.None is not valid for this method"); + if (query.Contains(' ')) + return GracefulException.BadRequest("Invalid query"); if (query.StartsWith($"https://{config.Value.WebDomain}/notes/")) - throw GracefulException.BadRequest("Refusing to resolve local note URL as user"); + return GracefulException.BadRequest("Refusing to resolve local note URL as user"); + if (query.StartsWith("acct:") && !flags.HasFlag(ResolveFlags.Acct)) + return GracefulException.BadRequest("Refusing to resolve acct: uri without ResolveFlags.Acct"); + if ((query.StartsWith("https://") || query.StartsWith("http://")) && !flags.HasFlag(ResolveFlags.Uri)) + return GracefulException.BadRequest("Refusing to resolve http(s): uri without !allowUri"); // First, let's see if we already know the user - var user = await userSvc.GetUserFromQueryAsync(query); + var user = await userSvc.GetUserFromQueryAsync(query, flags.HasFlag(ResolveFlags.MatchUrl)); if (user != null) return user; + // If query still starts with web domain, we can return early + if (query.StartsWith($"https://{config.Value.WebDomain}/")) + throw GracefulException.NotFound("No match found"); + + if (flags.HasFlag(ResolveFlags.OnlyExisting)) + return GracefulException.NotFound("No match found & OnlyExisting flag is set"); + // We don't, so we need to run WebFinger var (acct, uri) = await WebFingerAsync(query); // Check the database again with the new data - if (uri != query) user = await userSvc.GetUserFromQueryAsync(uri); - if (user == null && acct != query) await userSvc.GetUserFromQueryAsync(acct); - if (user != null) return user; + if (uri != query) + user = await userSvc.GetUserFromQueryAsync(uri, allowUrl: false); + // If there's still no match, try looking it up by acct returned by WebFinger + if (user == null && acct != query) + { + user = await userSvc.GetUserFromQueryAsync(acct, allowUrl: false); + if (user != null && !flags.HasFlag(ResolveFlags.Acct)) + return GracefulException.BadRequest($"User with acct {acct} is known, but Acct flag is not set"); + } + + if (flags.HasFlag(ResolveFlags.EnforceUri) && user != null && user.GetUriOrPublicUri(config.Value) != query) + return GracefulException.BadRequest("Refusing to return user with mismatching uri with EnforceUri flag"); + + if (user != null) + return user; + + if (flags.HasFlag(ResolveFlags.EnforceUri) && uri != query) + return GracefulException.BadRequest("Refusing to create user with mismatching uri with EnforceUri flag"); + + // All checks passed & we still don't know the user, so we pass the job on to userSvc, which will create it using (await KeyedLocker.LockAsync(uri)) { - // Pass the job on to userSvc, which will create the user return await userSvc.CreateUserAsync(uri, acct); } } - public async Task ResolveAsync(string query, bool onlyExisting) + public async Task ResolveAsync(string query, ResolveFlags flags) { - query = NormalizeQuery(query); - - // Before we begin, let's skip local note urls - if (query.StartsWith($"https://{config.Value.WebDomain}/notes/")) - throw GracefulException.BadRequest("Refusing to resolve local note URL as user"); - - // First, let's see if we already know the user - var user = await userSvc.GetUserFromQueryAsync(query); - if (user != null) return user; - - if (onlyExisting) - return null; - - // We don't, so we need to run WebFinger - var (acct, uri) = await WebFingerAsync(query); - - // Check the database again with the new data - if (uri != query) user = await userSvc.GetUserFromQueryAsync(uri); - if (user == null && acct != query) await userSvc.GetUserFromQueryAsync(acct); - if (user != null) return user; - - using (await KeyedLocker.LockAsync(uri)) + return await ResolveInternalAsync(query, flags) switch { - // Pass the job on to userSvc, which will create the user - return await userSvc.CreateUserAsync(uri, acct); - } + Result.Success result => result.Result, + Result.Failure failure => throw failure.Error, + _ => throw new ArgumentOutOfRangeException() + }; } - public async Task ResolveAsyncOrNull(string username, string? host) + public async Task ResolveOrNullAsync(string query, ResolveFlags flags) { + string errorMessage; try { - var query = $"acct:{username}@{host}"; - - // First, let's see if we already know the user - var user = await userSvc.GetUserFromQueryAsync(query); - if (user != null) return user; - - if (host == null) return null; - - // We don't, so we need to run WebFinger - var (acct, uri) = await WebFingerAsync(query); - - // Check the database again with the new data - if (uri != query) user = await userSvc.GetUserFromQueryAsync(uri); - if (user == null && acct != query) await userSvc.GetUserFromQueryAsync(acct); - if (user != null) return user; - - using (await KeyedLocker.LockAsync(uri)) - { - // Pass the job on to userSvc, which will create the user - return await userSvc.CreateUserAsync(uri, acct); - } + var res = await ResolveInternalAsync(query, flags); + if (res.TryGetResult(out var result)) return result; + if (!res.TryGetError(out var error)) throw new Exception("Return value neither result nor error"); + if (error is GracefulException { StatusCode: HttpStatusCode.NotFound }) return null; + errorMessage = error.Message; } - catch + catch (GracefulException ge) when (ge.StatusCode == HttpStatusCode.NotFound) { return null; } - } - - public async Task ResolveAsyncOrNull(string query) - { - try + catch (Exception e) { - query = NormalizeQuery(query); - - // First, let's see if we already know the user - var user = await userSvc.GetUserFromQueryAsync(query); - if (user != null) return user; - - if (query.StartsWith($"https://{config.Value.WebDomain}/")) return null; - - // We don't, so we need to run WebFinger - var (acct, resolvedUri) = await WebFingerAsync(query); - - // Check the database again with the new data - if (resolvedUri != query) user = await userSvc.GetUserFromQueryAsync(resolvedUri); - if (user == null && acct != query) await userSvc.GetUserFromQueryAsync(acct); - if (user != null) return user; - - using (await KeyedLocker.LockAsync(resolvedUri)) - { - // Pass the job on to userSvc, which will create the user - return await userSvc.CreateUserAsync(resolvedUri, acct); - } - } - catch - { - return null; + errorMessage = e.Message; } + + logger.LogDebug("Failed to resolve user: {error}", errorMessage); + return null; } public async Task GetUpdatedUser(User user) diff --git a/Iceshrimp.Backend/Core/Helpers/Result.cs b/Iceshrimp.Backend/Core/Helpers/Result.cs new file mode 100644 index 00000000..6c34e7f0 --- /dev/null +++ b/Iceshrimp.Backend/Core/Helpers/Result.cs @@ -0,0 +1,83 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Iceshrimp.Backend.Core.Helpers; + +public abstract record Result + where TResult : class + where TError : class +{ + private Result() { } + + public sealed record Success(TResult Result) : Result; + + public sealed record Failure(TError Error) : Result; + + public static implicit operator Result(TResult result) => new Success(result); + public static implicit operator Result(TError error) => new Failure(error); + + public bool TryGetResult([NotNullWhen(true)] out TResult? result) + { + if (this is Success s) + { + result = s.Result; + return true; + } + + result = null; + return false; + } + + public bool TryGetError([NotNullWhen(true)] out TError? error) + { + if (this is Failure f) + { + error = f.Error; + return true; + } + + error = null; + return false; + } + + public bool IsSuccess => this is Success; + public bool IsFailure => this is Failure; +} + +public abstract record Result where TResult : class +{ + private Result() { } + + public sealed record Success(TResult Result) : Result; + + public sealed record Failure(Exception Error) : Result; + + public static implicit operator Result(TResult result) => new Success(result); + public static implicit operator Result(Exception error) => new Failure(error); + + public bool TryGetResult([NotNullWhen(true)] out TResult? result) + { + if (this is Success s) + { + result = s.Result; + return true; + } + + result = null; + return false; + } + + public bool TryGetError([NotNullWhen(true)] out Exception? error) + { + if (this is Failure f) + { + error = f.Error; + return true; + } + + error = null; + return false; + } + + public bool IsSuccess => this is Success; + public bool IsFailure => this is Failure; +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Middleware/AuthorizedFetchMiddleware.cs b/Iceshrimp.Backend/Core/Middleware/AuthorizedFetchMiddleware.cs index e5a6a7d5..26526834 100644 --- a/Iceshrimp.Backend/Core/Middleware/AuthorizedFetchMiddleware.cs +++ b/Iceshrimp.Backend/Core/Middleware/AuthorizedFetchMiddleware.cs @@ -7,6 +7,7 @@ using Iceshrimp.Backend.Core.Federation.Cryptography; using Iceshrimp.Backend.Core.Services; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; +using static Iceshrimp.Backend.Core.Federation.ActivityPub.UserResolver; namespace Iceshrimp.Backend.Core.Middleware; @@ -66,7 +67,7 @@ public class AuthorizedFetchMiddleware( { try { - var user = await userResolver.ResolveAsync(sig.KeyId).WaitAsync(ct); + var user = await userResolver.ResolveAsync(sig.KeyId, ResolveFlags.Uri).WaitAsync(ct); key = await db.UserPublickeys.Include(p => p.User) .FirstOrDefaultAsync(p => p.User == user, ct); diff --git a/Iceshrimp.Backend/Core/Middleware/InboxValidationMiddleware.cs b/Iceshrimp.Backend/Core/Middleware/InboxValidationMiddleware.cs index 4bebf99e..e8f79b16 100644 --- a/Iceshrimp.Backend/Core/Middleware/InboxValidationMiddleware.cs +++ b/Iceshrimp.Backend/Core/Middleware/InboxValidationMiddleware.cs @@ -12,6 +12,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using static Iceshrimp.Backend.Core.Federation.ActivityPub.UserResolver; namespace Iceshrimp.Backend.Core.Middleware; @@ -117,7 +118,11 @@ public class InboxValidationMiddleware( { try { - var user = await userResolver.ResolveAsync(sig.KeyId, activity is ASDelete).WaitAsync(ct); + var flags = activity is ASDelete + ? ResolveFlags.Uri | ResolveFlags.OnlyExisting + : ResolveFlags.Uri; + + var user = await userResolver.ResolveOrNullAsync(sig.KeyId, flags).WaitAsync(ct); if (user == null) throw AuthFetchException.NotFound("Delete activity actor is unknown"); key = await db.UserPublickeys.Include(p => p.User) .FirstOrDefaultAsync(p => p.User == user, ct); @@ -196,8 +201,13 @@ public class InboxValidationMiddleware( if (key == null) { - var user = await userResolver.ResolveAsync(activity.Actor.Id, activity is ASDelete) - .WaitAsync(ct); + var flags = activity is ASDelete + ? ResolveFlags.Uri | ResolveFlags.OnlyExisting + : ResolveFlags.Uri; + + var user = await userResolver + .ResolveOrNullAsync(activity.Actor.Id, flags) + .WaitAsync(ct); if (user == null) throw AuthFetchException.NotFound("Delete activity actor is unknown"); key = await db.UserPublickeys .Include(p => p.User) diff --git a/Iceshrimp.Backend/Core/Services/NoteService.cs b/Iceshrimp.Backend/Core/Services/NoteService.cs index e43b0acf..27d3821a 100644 --- a/Iceshrimp.Backend/Core/Services/NoteService.cs +++ b/Iceshrimp.Backend/Core/Services/NoteService.cs @@ -16,6 +16,7 @@ using Iceshrimp.Backend.Core.Queues; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Newtonsoft.Json; +using static Iceshrimp.Backend.Core.Federation.ActivityPub.UserResolver; using static Iceshrimp.Parsing.MfmNodeTypes; namespace Iceshrimp.Backend.Core.Services; @@ -226,8 +227,9 @@ public class NoteService( if (data.ASNote != null) { visibleUserIds = (await data.ASNote.GetRecipients(data.User) - .Select(userResolver.ResolveAsync) + .Select(p => userResolver.ResolveOrNullAsync(p, EnforceUriFlags)) .AwaitAllNoConcurrencyAsync()) + .NotNull() .Select(p => p.Id) .Concat(mentionedUserIds) .Append(data.Reply?.UserId) @@ -566,8 +568,9 @@ public class NoteService( if (data.ASNote != null) { visibleUserIds = (await data.ASNote.GetRecipients(note.User) - .Select(userResolver.ResolveAsync) + .Select(p => userResolver.ResolveOrNullAsync(p, EnforceUriFlags)) .AwaitAllNoConcurrencyAsync()) + .NotNull() .Select(p => p.Id) .Concat(visibleUserIds) .ToList(); @@ -1044,22 +1047,12 @@ public class NoteService( private async Task ResolveNoteMentionsAsync(ASNote note) { - var mentionTags = note.Tags?.OfType().Where(p => p.Href != null) ?? []; + var mentionTags = note.Tags?.OfType().Where(p => p.Href?.Id != null) ?? []; var users = await mentionTags - .Select(async p => - { - try - { - return await userResolver.ResolveAsync(p.Href!.Id!); - } - catch - { - return null; - } - }) + .Select(p => userResolver.ResolveOrNullAsync(p.Href!.Id!, EnforceUriFlags)) .AwaitAllNoConcurrencyAsync(); - return ResolveNoteMentions(users.Where(p => p != null).Select(p => p!).ToList()); + return ResolveNoteMentions(users.NotNull().ToList()); } private async Task ResolveNoteMentionsAsync(string? text) @@ -1069,21 +1062,11 @@ public class NoteService( .SelectMany(p => p.Children.Append(p)) .OfType() .DistinctBy(p => p.Acct) - .Select(async p => - { - try - { - return await userResolver.ResolveAsync(p.Acct); - } - catch - { - return null; - } - }) + .Select(p => userResolver.ResolveOrNullAsync(p.Acct, ResolveFlags.Acct)) .AwaitAllNoConcurrencyAsync() : []; - return ResolveNoteMentions(users.Where(p => p != null).Select(p => p!).ToList()); + return ResolveNoteMentions(users.NotNull().ToList()); } private List ResolveHashtags(string? text, ASNote? note = null) @@ -1203,6 +1186,9 @@ public class NoteService( return await db.Notes.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id); } + if (uri.StartsWith($"https://{config.Value.WebDomain}/")) + return null; + var note = await db.Notes.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Uri == uri); if (note != null && !forceRefresh) return note; @@ -1249,7 +1235,7 @@ public class NoteService( if (res != null && !forceRefresh) return res; } - var actor = await userResolver.ResolveAsync(attrTo.Id); + var actor = await userResolver.ResolveAsync(attrTo.Id, EnforceUriFlags); using (await KeyedLocker.LockAsync(uri)) { diff --git a/Iceshrimp.Backend/Core/Services/UserProfileMentionsResolver.cs b/Iceshrimp.Backend/Core/Services/UserProfileMentionsResolver.cs index 9b0b92f6..cca2352b 100644 --- a/Iceshrimp.Backend/Core/Services/UserProfileMentionsResolver.cs +++ b/Iceshrimp.Backend/Core/Services/UserProfileMentionsResolver.cs @@ -6,6 +6,7 @@ using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types; using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion; using Iceshrimp.Backend.Core.Helpers.LibMfm.Parsing; using Microsoft.Extensions.Options; +using static Iceshrimp.Backend.Core.Federation.ActivityPub.UserResolver; using static Iceshrimp.Parsing.MfmNodeTypes; namespace Iceshrimp.Backend.Core.Services; @@ -39,16 +40,16 @@ public class UserProfileMentionsResolver(ActivityPub.UserResolver userResolver, var users = await mentionNodes .DistinctBy(p => p.Acct) - .Select(async p => await userResolver.ResolveAsyncOrNull(p.Username, p.Host?.Value ?? host)) + .Select(p => userResolver.ResolveOrNullAsync(GetQuery(p.Username, p.Host?.Value ?? host), + ResolveFlags.Acct)) .AwaitAllNoConcurrencyAsync(); users.AddRange(await userUris .Distinct() - .Select(async p => await userResolver.ResolveAsyncOrNull(p)) + .Select(p => userResolver.ResolveOrNullAsync(p, EnforceUriFlags)) .AwaitAllNoConcurrencyAsync()); - var mentions = users.Where(p => p != null) - .Cast() + var mentions = users.NotNull() .DistinctBy(p => p.Id) .Select(p => new Note.MentionedUser { @@ -82,11 +83,11 @@ public class UserProfileMentionsResolver(ActivityPub.UserResolver userResolver, var mentionNodes = EnumerateMentions(nodes); var users = await mentionNodes .DistinctBy(p => p.Acct) - .Select(async p => await userResolver.ResolveAsyncOrNull(p.Username, p.Host?.Value ?? host)) + .Select(p => userResolver.ResolveOrNullAsync(GetQuery(p.Username, p.Host?.Value ?? host), + ResolveFlags.Acct)) .AwaitAllNoConcurrencyAsync(); - return users.Where(p => p != null) - .Cast() + return users.NotNull() .DistinctBy(p => p.Id) .Select(p => new Note.MentionedUser { diff --git a/Iceshrimp.Backend/Core/Services/UserService.cs b/Iceshrimp.Backend/Core/Services/UserService.cs index 27404b05..21c9789f 100644 --- a/Iceshrimp.Backend/Core/Services/UserService.cs +++ b/Iceshrimp.Backend/Core/Services/UserService.cs @@ -58,32 +58,37 @@ public class UserService( : (split[0], split[1].ToPunycodeLower()); } - public async Task GetUserFromQueryAsync(string query) + public async Task GetUserFromQueryAsync(string query, bool allowUrl) { if (query.StartsWith("http://") || query.StartsWith("https://")) + { if (query.StartsWith($"https://{instance.Value.WebDomain}/users/")) { query = query[$"https://{instance.Value.WebDomain}/users/".Length..]; return await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == query) ?? throw GracefulException.NotFound("User not found"); } - else if (query.StartsWith($"https://{instance.Value.WebDomain}/@")) + + if (query.StartsWith($"https://{instance.Value.WebDomain}/@")) { query = query[$"https://{instance.Value.WebDomain}/@".Length..]; if (query.Split('@').Length != 1) - return await GetUserFromQueryAsync($"acct:{query}"); + return await GetUserFromQueryAsync($"acct:{query}", allowUrl); return await db.Users.IncludeCommonProperties() - .FirstOrDefaultAsync(p => p.Username == query.ToLower()) ?? + .FirstOrDefaultAsync(p => p.Username == query.ToLower() && p.IsLocalUser) ?? throw GracefulException.NotFound("User not found"); } - else - { - return await db.Users - .IncludeCommonProperties() - .FirstOrDefaultAsync(p => (p.Uri != null && p.Uri == query) || - (p.UserProfile != null && p.UserProfile.Url == query)); - } + + var res = await db.Users.IncludeCommonProperties() + .FirstOrDefaultAsync(p => p.Uri != null && p.Uri == query); + + if (res != null || !allowUrl) + return res; + + return await db.Users.IncludeCommonProperties() + .FirstOrDefaultAsync(p => p.UserProfile != null && p.UserProfile.Url == query); + } var tuple = AcctToTuple(query); if (tuple.Host == instance.Value.WebDomain || tuple.Host == instance.Value.AccountDomain)