[backend/federation] Refactor UserResolver (ISH-548)

This commit is contained in:
Laura Hausmann 2024-10-24 01:03:01 +02:00
parent e753bacb1d
commit f19a414b27
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
13 changed files with 276 additions and 177 deletions

View file

@ -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<AccountEntity> 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);
}

View file

@ -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)],
_ => []
};
}
}
}

View file

@ -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");

View file

@ -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");

View file

@ -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");

View file

@ -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)

View file

@ -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<User> 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}");
/// <summary>
/// No flags. Do not use.
/// </summary>
None = 0,
/// <summary>
/// Whether to allow http/s: queries.
/// </summary>
Uri = 1,
/// <summary>
/// Whether to allow acct: queries.
/// </summary>
Acct = 2,
/// <summary>
/// Whether to match user urls in addition to user uris. Does nothing if the Uri flag is not set.
/// </summary>
MatchUrl = 4,
/// <summary>
/// Whether to enforce that the returned object has the same URL as the query. Does nothing if the Uri flag is not set.
/// </summary>
EnforceUri = 8,
/// <summary>
/// Return early if no matching user was found in the database
/// </summary>
OnlyExisting = 16
}
public async Task<User?> 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<User> ResolveAsync(string query)
/// <summary>
/// Resolves a local or remote user.
/// </summary>
/// <param name="query">The query to resolve. Can be an acct (with acct: or @ prefix), </param>
/// <param name="flags">The options to use for the resolve process.</param>
/// <returns>The user in question.</returns>
private async Task<Result<User>> 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<User?> ResolveAsync(string query, bool onlyExisting)
public async Task<User> 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<User>.Success result => result.Result,
Result<User>.Failure failure => throw failure.Error,
_ => throw new ArgumentOutOfRangeException()
};
}
public async Task<User?> ResolveAsyncOrNull(string username, string? host)
public async Task<User?> 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<User?> 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<User> GetUpdatedUser(User user)

View file

@ -0,0 +1,83 @@
using System.Diagnostics.CodeAnalysis;
namespace Iceshrimp.Backend.Core.Helpers;
public abstract record Result<TResult, TError>
where TResult : class
where TError : class
{
private Result() { }
public sealed record Success(TResult Result) : Result<TResult, TError>;
public sealed record Failure(TError Error) : Result<TResult, TError>;
public static implicit operator Result<TResult, TError>(TResult result) => new Success(result);
public static implicit operator Result<TResult, TError>(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<TResult> where TResult : class
{
private Result() { }
public sealed record Success(TResult Result) : Result<TResult>;
public sealed record Failure(Exception Error) : Result<TResult>;
public static implicit operator Result<TResult>(TResult result) => new Success(result);
public static implicit operator Result<TResult>(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;
}

View file

@ -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);

View file

@ -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)

View file

@ -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<NoteMentionData> ResolveNoteMentionsAsync(ASNote note)
{
var mentionTags = note.Tags?.OfType<ASMention>().Where(p => p.Href != null) ?? [];
var mentionTags = note.Tags?.OfType<ASMention>().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<NoteMentionData> ResolveNoteMentionsAsync(string? text)
@ -1069,21 +1062,11 @@ public class NoteService(
.SelectMany(p => p.Children.Append(p))
.OfType<MfmMentionNode>()
.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<string> 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))
{

View file

@ -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<User>()
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<User>()
return users.NotNull()
.DistinctBy(p => p.Id)
.Select(p => new Note.MentionedUser
{

View file

@ -58,32 +58,37 @@ public class UserService(
: (split[0], split[1].ToPunycodeLower());
}
public async Task<User?> GetUserFromQueryAsync(string query)
public async Task<User?> 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)