355 lines
No EOL
13 KiB
C#
355 lines
No EOL
13 KiB
C#
using System.Diagnostics.CodeAnalysis;
|
|
using System.Net;
|
|
using System.Security.Cryptography;
|
|
using EntityFramework.Exceptions.Common;
|
|
using Iceshrimp.Backend.Core.Configuration;
|
|
using Iceshrimp.Backend.Core.Database;
|
|
using Iceshrimp.Backend.Core.Database.Tables;
|
|
using Iceshrimp.Backend.Core.Extensions;
|
|
using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
|
|
using Iceshrimp.Backend.Core.Helpers;
|
|
using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion;
|
|
using Iceshrimp.Backend.Core.Middleware;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace Iceshrimp.Backend.Core.Services;
|
|
|
|
public class UserService(
|
|
[SuppressMessage("ReSharper", "SuggestBaseTypeForParameterInConstructor")]
|
|
IOptionsSnapshot<Config.SecuritySection> security,
|
|
IOptions<Config.InstanceSection> instance,
|
|
ILogger<UserService> logger,
|
|
DatabaseContext db,
|
|
ActivityPub.ActivityFetcherService fetchSvc,
|
|
DriveService driveSvc,
|
|
MfmConverter mfmConverter,
|
|
FollowupTaskService followupTaskSvc
|
|
)
|
|
{
|
|
private (string Username, string? Host) AcctToTuple(string acct)
|
|
{
|
|
if (!acct.StartsWith("acct:")) throw new GracefulException(HttpStatusCode.BadRequest, "Invalid query");
|
|
|
|
var split = acct[5..].Split('@');
|
|
if (split.Length != 2)
|
|
return (split[0], instance.Value.AccountDomain.ToPunycode());
|
|
|
|
return (split[0], split[1].ToPunycode());
|
|
}
|
|
|
|
public async Task<User?> GetUserFromQueryAsync(string query)
|
|
{
|
|
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
|
|
{
|
|
return await db.Users
|
|
.IncludeCommonProperties()
|
|
.FirstOrDefaultAsync(p => p.Uri != null && p.Uri.ToLower() == query.ToLowerInvariant());
|
|
}
|
|
|
|
var tuple = AcctToTuple(query);
|
|
if (tuple.Host == instance.Value.WebDomain || tuple.Host == instance.Value.AccountDomain)
|
|
tuple.Host = null;
|
|
return await db.Users
|
|
.IncludeCommonProperties()
|
|
.FirstOrDefaultAsync(p => p.UsernameLower == tuple.Username.ToLowerInvariant() &&
|
|
p.Host == tuple.Host);
|
|
}
|
|
|
|
public async Task<User> CreateUserAsync(string uri, string acct)
|
|
{
|
|
logger.LogDebug("Creating user {acct} with uri {uri}", acct, uri);
|
|
var actor = await fetchSvc.FetchActorAsync(uri);
|
|
logger.LogDebug("Got actor: {url}", actor.Url);
|
|
|
|
actor.Normalize(uri, acct);
|
|
|
|
if (actor.PublicKey?.Id == null || actor.PublicKey?.PublicKey == null)
|
|
throw new GracefulException(HttpStatusCode.UnprocessableEntity, "Actor has no valid public key");
|
|
|
|
var user = await db.Users
|
|
.IncludeCommonProperties()
|
|
.FirstOrDefaultAsync(p => p.Uri != null && p.Uri == actor.Id);
|
|
|
|
if (user != null)
|
|
{
|
|
// Another thread got there first
|
|
logger.LogDebug("Actor {uri} is already known, returning existing user {id}", user.Uri, user.Id);
|
|
return user;
|
|
}
|
|
|
|
user = new User
|
|
{
|
|
Id = IdHelpers.GenerateSlowflakeId(),
|
|
CreatedAt = DateTime.UtcNow,
|
|
LastFetchedAt = DateTime.UtcNow,
|
|
DisplayName = actor.DisplayName,
|
|
IsLocked = actor.IsLocked ?? false,
|
|
IsBot = actor.IsBot,
|
|
Username = actor.Username!,
|
|
UsernameLower = actor.Username!.ToLowerInvariant(),
|
|
Host = AcctToTuple(acct).Host,
|
|
MovedToUri = actor.MovedTo?.Link,
|
|
AlsoKnownAs = actor.AlsoKnownAs?.Where(p => p.Link != null).Select(p => p.Link!).ToList(),
|
|
IsExplorable = actor.IsDiscoverable ?? false,
|
|
Inbox = actor.Inbox?.Link,
|
|
SharedInbox = actor.SharedInbox?.Link ?? actor.Endpoints?.SharedInbox?.Id,
|
|
FollowersUri = actor.Followers?.Id,
|
|
Uri = actor.Id,
|
|
IsCat = actor.IsCat ?? false,
|
|
Featured = actor.Featured?.Link,
|
|
//TODO: FollowersCount
|
|
//TODO: FollowingCount
|
|
Emojis = [], //FIXME
|
|
Tags = [] //FIXME
|
|
};
|
|
|
|
var profile = new UserProfile
|
|
{
|
|
User = user,
|
|
Description = actor.MkSummary ?? await mfmConverter.FromHtmlAsync(actor.Summary),
|
|
//Birthday = TODO,
|
|
//Location = TODO,
|
|
//Fields = TODO,
|
|
UserHost = user.Host,
|
|
Url = actor.Url?.Link
|
|
};
|
|
|
|
var publicKey = new UserPublickey
|
|
{
|
|
UserId = user.Id, KeyId = actor.PublicKey.Id, KeyPem = actor.PublicKey.PublicKey
|
|
};
|
|
|
|
try
|
|
{
|
|
await db.AddRangeAsync(user, profile, publicKey);
|
|
|
|
// We need to do this after calling db.Add(Range) to ensure data consistency
|
|
var processPendingDeletes = await ResolveAvatarAndBanner(user, actor);
|
|
await db.SaveChangesAsync();
|
|
await processPendingDeletes();
|
|
return user;
|
|
}
|
|
catch (UniqueConstraintException)
|
|
{
|
|
logger.LogDebug("Encountered UniqueConstraintException while creating user {uri}, attempting to refetch...",
|
|
user.Uri);
|
|
// another thread got there first, so we need to return the existing user
|
|
var res = await db.Users
|
|
.IncludeCommonProperties()
|
|
.FirstOrDefaultAsync(p => p.Uri != null && p.Uri == user.Uri);
|
|
|
|
// something else must have went wrong, rethrow exception
|
|
if (res == null)
|
|
{
|
|
logger.LogError("Fetching user {uri} failed, rethrowing exception", user.Uri);
|
|
throw;
|
|
}
|
|
|
|
logger.LogDebug("Successfully fetched user {uri}", user.Uri);
|
|
|
|
return res;
|
|
}
|
|
}
|
|
|
|
public async Task<User> UpdateUserAsync(string id)
|
|
{
|
|
var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id) ??
|
|
throw new Exception("Cannot update nonexistent user");
|
|
return await UpdateUserAsync(user, force: true);
|
|
}
|
|
|
|
public async Task<User> UpdateUserAsync(User user, ASActor? actor = null, bool force = false)
|
|
{
|
|
if (!user.NeedsUpdate && actor == null && !force) return user;
|
|
if (actor is { IsUnresolved: true } or { Username: null })
|
|
actor = null; // This will trigger a fetch a couple lines down
|
|
|
|
// Prevent multiple update jobs from running concurrently
|
|
db.Update(user);
|
|
await db.Users.Where(u => u.Id == user.Id)
|
|
.ExecuteUpdateAsync(p => p.SetProperty(u => u.LastFetchedAt, DateTime.UtcNow));
|
|
|
|
var uri = user.Uri ?? throw new Exception("Encountered remote user without a Uri");
|
|
logger.LogDebug("Updating user with uri {uri}", uri);
|
|
|
|
actor ??= await fetchSvc.FetchActorAsync(user.Uri);
|
|
actor.Normalize(uri, user.AcctWithPrefix);
|
|
|
|
user.UserProfile ??= await db.UserProfiles.FirstOrDefaultAsync(p => p.User == user);
|
|
user.UserProfile ??= new UserProfile { User = user };
|
|
|
|
user.LastFetchedAt = DateTime.UtcNow; // If we don't do this we'll overwrite the value with the previous one
|
|
user.Inbox = actor.Inbox?.Link;
|
|
user.SharedInbox = actor.SharedInbox?.Link ?? actor.Endpoints?.SharedInbox?.Id;
|
|
user.DisplayName = actor.DisplayName;
|
|
user.IsLocked = actor.IsLocked ?? false;
|
|
user.IsBot = actor.IsBot;
|
|
user.MovedToUri = actor.MovedTo?.Link;
|
|
user.AlsoKnownAs = actor.AlsoKnownAs?.Where(p => p.Link != null).Select(p => p.Link!).ToList();
|
|
user.IsExplorable = actor.IsDiscoverable ?? false;
|
|
user.FollowersUri = actor.Followers?.Id;
|
|
user.IsCat = actor.IsCat ?? false;
|
|
user.Featured = actor.Featured?.Link;
|
|
user.Emojis = []; //FIXME
|
|
user.Tags = []; //FIXME
|
|
//TODO: FollowersCount
|
|
//TODO: FollowingCount
|
|
|
|
//TODO: update acct host via webfinger here
|
|
|
|
var processPendingDeletes = await ResolveAvatarAndBanner(user, actor);
|
|
|
|
user.UserProfile.Description = actor.MkSummary ?? await mfmConverter.FromHtmlAsync(actor.Summary);
|
|
//user.UserProfile.Birthday = TODO;
|
|
//user.UserProfile.Location = TODO;
|
|
//user.UserProfile.Fields = TODO;
|
|
user.UserProfile.UserHost = user.Host;
|
|
user.UserProfile.Url = actor.Url?.Link;
|
|
|
|
db.Update(user);
|
|
await db.SaveChangesAsync();
|
|
await processPendingDeletes();
|
|
return user;
|
|
}
|
|
|
|
public async Task<User> CreateLocalUserAsync(string username, string password, string? invite)
|
|
{
|
|
//TODO: invite system should allow multi-use invites & time limited invites
|
|
if (security.Value.Registrations == Enums.Registrations.Closed)
|
|
throw new GracefulException(HttpStatusCode.Forbidden, "Registrations are disabled on this server");
|
|
if (security.Value.Registrations == Enums.Registrations.Invite && invite == null)
|
|
throw new GracefulException(HttpStatusCode.Forbidden, "Request is missing the invite code");
|
|
if (security.Value.Registrations == Enums.Registrations.Invite &&
|
|
!await db.RegistrationInvites.AnyAsync(p => p.Code == invite))
|
|
throw new GracefulException(HttpStatusCode.Forbidden, "The specified invite code is invalid");
|
|
if (username.Contains('.'))
|
|
throw new GracefulException(HttpStatusCode.BadRequest, "Username must not contain the dot character");
|
|
if (Constants.SystemUsers.Contains(username.ToLowerInvariant()))
|
|
throw new GracefulException(HttpStatusCode.BadRequest, "Username must not be a system user");
|
|
if (await db.Users.AnyAsync(p => p.Host == null && p.UsernameLower == username.ToLowerInvariant()))
|
|
throw new GracefulException(HttpStatusCode.BadRequest, "User already exists");
|
|
if (await db.UsedUsernames.AnyAsync(p => p.Username.ToLower() == username.ToLowerInvariant()))
|
|
throw new GracefulException(HttpStatusCode.BadRequest, "Username was already used");
|
|
if (password.Length < 8)
|
|
throw GracefulException.BadRequest("Password must be at least 8 characters long");
|
|
|
|
var keypair = RSA.Create(4096);
|
|
var user = new User
|
|
{
|
|
Id = IdHelpers.GenerateSlowflakeId(),
|
|
CreatedAt = DateTime.UtcNow,
|
|
Username = username,
|
|
UsernameLower = username.ToLowerInvariant(),
|
|
Host = null
|
|
};
|
|
|
|
var userKeypair = new UserKeypair
|
|
{
|
|
UserId = user.Id,
|
|
PrivateKey = keypair.ExportPkcs8PrivateKeyPem(),
|
|
PublicKey = keypair.ExportSubjectPublicKeyInfoPem()
|
|
};
|
|
|
|
var userProfile = new UserProfile { UserId = user.Id, Password = AuthHelpers.HashPassword(password) };
|
|
|
|
var usedUsername = new UsedUsername { CreatedAt = DateTime.UtcNow, Username = username.ToLowerInvariant() };
|
|
|
|
if (security.Value.Registrations == Enums.Registrations.Invite)
|
|
{
|
|
var ticket = await db.RegistrationInvites.FirstOrDefaultAsync(p => p.Code == invite);
|
|
if (ticket == null)
|
|
throw GracefulException.Forbidden("The specified invite code is invalid");
|
|
db.Remove(ticket);
|
|
}
|
|
|
|
await db.AddRangeAsync(user, userKeypair, userProfile, usedUsername);
|
|
await db.SaveChangesAsync();
|
|
|
|
return user;
|
|
}
|
|
|
|
private async Task<Func<Task>> ResolveAvatarAndBanner(User user, ASActor actor)
|
|
{
|
|
var avatar = await driveSvc.StoreFile(actor.Avatar?.Url?.Link, user, actor.Avatar?.Sensitive ?? false);
|
|
var banner = await driveSvc.StoreFile(actor.Banner?.Url?.Link, user, actor.Banner?.Sensitive ?? false);
|
|
|
|
var prevAvatarId = user.AvatarId;
|
|
var prevBannerId = user.BannerId;
|
|
|
|
user.Avatar = avatar;
|
|
user.Banner = banner;
|
|
|
|
user.AvatarBlurhash = avatar?.Blurhash;
|
|
user.BannerBlurhash = banner?.Blurhash;
|
|
|
|
user.AvatarUrl = avatar?.Url;
|
|
user.BannerUrl = banner?.Url;
|
|
|
|
return async () =>
|
|
{
|
|
if (prevAvatarId != null && avatar?.Id != prevAvatarId)
|
|
await driveSvc.RemoveFile(prevAvatarId);
|
|
|
|
if (prevBannerId != null && banner?.Id != prevBannerId)
|
|
await driveSvc.RemoveFile(prevBannerId);
|
|
};
|
|
}
|
|
|
|
public async Task<UserPublickey> UpdateUserPublicKeyAsync(UserPublickey key)
|
|
{
|
|
var uri = key.User.Uri ?? throw new Exception("Can't update public key of user without Uri");
|
|
var actor = await fetchSvc.FetchActorAsync(uri);
|
|
|
|
if (actor.PublicKey?.PublicKey == null)
|
|
throw new Exception("Failed to update user public key: Invalid or missing public key");
|
|
|
|
key.KeyId = actor.PublicKey.Id;
|
|
key.KeyPem = actor.PublicKey.PublicKey;
|
|
|
|
db.Update(key);
|
|
await db.SaveChangesAsync();
|
|
return key;
|
|
}
|
|
|
|
public async Task DeleteUserAsync(ASActor actor)
|
|
{
|
|
var user = await db.Users
|
|
.Include(user => user.Avatar)
|
|
.Include(user => user.Banner)
|
|
.FirstOrDefaultAsync(p => p.Uri == actor.Id && p.Host != null);
|
|
|
|
if (user == null)
|
|
{
|
|
logger.LogDebug("User {uri} is unknown, skipping delete task", actor.Id);
|
|
return;
|
|
}
|
|
|
|
db.Remove(user);
|
|
await db.SaveChangesAsync();
|
|
|
|
if (user.Avatar != null)
|
|
await driveSvc.RemoveFile(user.Avatar);
|
|
if (user.Banner != null)
|
|
await driveSvc.RemoveFile(user.Banner);
|
|
}
|
|
|
|
public void UpdateUserLastActive(User user)
|
|
{
|
|
if (user.LastActiveDate != null && user.LastActiveDate > DateTime.UtcNow - TimeSpan.FromHours(1)) return;
|
|
|
|
_ = followupTaskSvc.ExecuteTask("UpdateUserLastActive", async provider =>
|
|
{
|
|
var bgDb = provider.GetRequiredService<DatabaseContext>();
|
|
await bgDb.Users.Where(p => p.Id == user.Id)
|
|
.ExecuteUpdateAsync(p => p.SetProperty(u => u.LastActiveDate, DateTime.UtcNow));
|
|
});
|
|
}
|
|
} |