[backend/federation] Refactor UserResolver (ISH-548)
This commit is contained in:
parent
e753bacb1d
commit
f19a414b27
13 changed files with 276 additions and 177 deletions
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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)],
|
||||
_ => []
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
83
Iceshrimp.Backend/Core/Helpers/Result.cs
Normal file
83
Iceshrimp.Backend/Core/Helpers/Result.cs
Normal 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;
|
||||
}
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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))
|
||||
{
|
||||
|
|
|
@ -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
|
||||
{
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Add table
Reference in a new issue