Files
SocialPub/PrivaPub/Services/ClientToServer/Private/IPrivateAvatarUsersService.cs
T
thepraandClaude Opus 5.5 d2f0c7a14e Unique indexes, one username space, and entity maps warmed before use
Infrastructure/Data/Indexes runs at every start, after the migrations:
unique on Post/DmPost ObjectURI, ForeignAvatar ActorURI, the Follower
triple, RootToAvatar, RootUser UserName and ReservedName; plain indexes on
the lookups the services actually make (PublicKeyId, author and group
post listings, ParticipantsKey, the delivery queue).

Migration _001 runs first and removes the duplicates the races could
already have left (keeping the newest actor row, the oldest post, the
accepted follower), then fills ReservedName from every avatar and group.

ReservedName is one username space for personas, groups and the instance:
a name is reserved by an insert the unique index arbitrates, before the
avatar or group is saved, so two simultaneous sign-ups cannot both get it.
A short list of names (admin, support, abuse, postmaster, ...) is never
available.

EntityMaps.Warm touches every entity's collection one at a time before
anything else runs. MongoDB.Entities maps the Entity base class on first
touch, and two types mapped at once throw "An item with the same key has
already been added" and stay broken for the life of the process; the
parallel test run hit it, and the delivery worker racing a request could
have too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 11:01:08 +02:00

200 lines
7.0 KiB
C#

using Microsoft.Extensions.Localization;
using MongoDB.Entities;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.User;
using PrivaPub.ClientModels.User.Avatar;
using PrivaPub.Models.Federation;
using PrivaPub.Models.User;
using PrivaPub.Resources;
using PrivaPub.StaticServices;
using PrivaPub.Federation.Actors;
namespace PrivaPub.Services.ClientToServer.Private
{
public interface IPrivateAvatarUsersService
{
Task<WebResult> UpdateAvatar(UpdateAvatarForm form);
Task<WebResult> InsertAvatar(InsertAvatarForm form);
Task<WebResult> GetRootAvatars(string rootUserId, CancellationToken token);
}
public class PrivateAvatarUsersService : IPrivateAvatarUsersService
{
readonly ILocalActorService _localActors;
readonly DbEntities _dbEntities;
readonly IStringLocalizer<GenericRes> _localizer;
readonly ILogger<PrivateAvatarUsersService> _logger;
public PrivateAvatarUsersService(ILocalActorService localActors,
DbEntities dbEntities,
IStringLocalizer<GenericRes> localizer,
ILogger<PrivateAvatarUsersService> logger)
{
_localActors = localActors;
_dbEntities = dbEntities;
_localizer = localizer;
_logger = logger;
}
public async Task<WebResult> InsertAvatar(InsertAvatarForm form)
{
var result = new WebResult();
try
{
var userName = form.UserName?.Trim().ToLowerInvariant();
if (string.IsNullOrEmpty(userName) || !userName.All(c => char.IsAsciiLetterOrDigit(c) || c == '_'))
return result.Invalidate(_localizer["The username may contain only letters, digits and underscores."]);
if (await _localActors.IsUserNameTaken(userName, default))
return result.Invalidate(_localizer["The username '{0}' is already take.", userName]);
if (!await IsValidRootUser(form.RootId))
return result.Invalidate(_localizer["You can't do this action because of your account status."]);
var (privateKey, publicKey) = Keys.NewKeyPair();
var newAvatar = new Avatar
{
Name = form.Name,
UserName = userName,
Biography = form.Biography,
Fields = form.Fields,
PersonalNote = form.PersonalNote,
PrivateKey = privateKey,
PublicKey = publicKey,
Domain = _localActors.BaseAddress
};
newAvatar.ID = (string)newAvatar.GenerateNewID();
if (!await _localActors.TryReserveUserName(userName, LocalActorKind.Person, newAvatar.ID, default))
return result.Invalidate(_localizer["The username '{0}' is already take.", userName]);
if (form.Settings is { IsDefault: false })
newAvatar.Settings = ToSettings(form.Settings);
var actor = _localActors.FromAvatar(newAvatar);
newAvatar.Url = actor.Uri;
newAvatar.InboxURL = actor.Inbox;
newAvatar.OutboxURL = actor.Outbox;
await DB.Default.SaveAsync(newAvatar);
await DB.Default.SaveAsync(new RootToAvatar { RootId = form.RootId, AvatarId = newAvatar.ID });
result.Data = ToView(newAvatar);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(PrivateAvatarUsersService)}.{nameof(InsertAvatar)}");
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
}
}
public async Task<WebResult> UpdateAvatar(UpdateAvatarForm form)
{
var result = new WebResult();
try
{
if (!await IsValidRootUser(form.RootId))
return result.Invalidate(_localizer["You can't do this action because of your account status."]);
var owns = await _dbEntities.RootToAvatars
.Match(ra => ra.RootId == form.RootId && ra.AvatarId == form.AvatarId)
.ExecuteAnyAsync();
if (!owns)
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
var avatar = await _dbEntities.Avatars.MatchID(form.AvatarId).ExecuteFirstAsync();
if (avatar == default || avatar.DeletionAt.HasValue)
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
avatar.Name = form.Name;
avatar.Biography = form.Biography;
avatar.Fields = form.Fields ?? new();
avatar.PersonalNote = form.PersonalNote;
if (form.Settings != default)
avatar.Settings = ToSettings(form.Settings);
avatar.UpdatedAt = DateTime.UtcNow;
await DB.Default.SaveAsync(avatar);
result.Data = ToView(avatar);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(PrivateAvatarUsersService)}.{nameof(UpdateAvatar)}");
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
}
}
public async Task<WebResult> GetRootAvatars(string rootUserId, CancellationToken token)
{
var result = new WebResult();
try
{
var avatarIds = (await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootUserId).ExecuteAsync(token))
.Select(ra => ra.AvatarId)
.ToList();
var avatars = avatarIds.Count == 0
? new List<Avatar>()
: await _dbEntities.Avatars.Match(a => avatarIds.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync(token);
result.Data = avatars.Select(ToView).ToList();
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(PrivateAvatarUsersService)}.{nameof(GetRootAvatars)}");
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
}
}
async ValueTask<bool> IsValidRootUser(string rootUserId) =>
!string.IsNullOrEmpty(rootUserId)
&& await _dbEntities.RootUsers.MatchID(rootUserId)
.Match(ru => !ru.DeletedAt.HasValue && !ru.IsBanned)
.ExecuteAnyAsync();
static AvatarSettings ToSettings(ViewAvatarSettings settings) => new()
{
IsDefault = settings.IsDefault,
DarkThemeIndexColour = settings.DarkThemeIndexColour,
IconsThemeIndexColour = settings.IconsThemeIndexColour,
LanguageCode = settings.LanguageCode,
LightThemeIndexColour = settings.LightThemeIndexColour,
PreferSystemTheming = settings.PreferSystemTheming,
ThemeIsDarkGray = settings.ThemeIsDarkGray,
ThemeIsDarkMode = settings.ThemeIsDarkMode,
ThemeIsLightGray = settings.ThemeIsLightGray
};
ViewAvatar ToView(Avatar avatar) => new()
{
Id = avatar.ID,
Url = avatar.Url,
Handle = _localActors.FromAvatar(avatar).Handle,
Name = avatar.Name,
UserName = avatar.UserName,
Biography = avatar.Biography,
Fields = avatar.Fields,
PictureURL = avatar.PictureURL,
ThumbnailURL = avatar.ThumbnailURL,
SharedPersonalContacts = avatar.SharedPersonalContacts,
AccountState = (ViewAvatarState)(int)avatar.AccountState,
ServerType = ViewAvatarServer.PrivaPub,
Settings = new()
{
IsDefault = avatar.Settings.IsDefault,
LanguageCode = avatar.Settings.LanguageCode,
IconsThemeIndexColour = avatar.Settings.IconsThemeIndexColour,
LightThemeIndexColour = avatar.Settings.LightThemeIndexColour,
DarkThemeIndexColour = avatar.Settings.DarkThemeIndexColour,
PreferSystemTheming = avatar.Settings.PreferSystemTheming,
ThemeIsDarkMode = avatar.Settings.ThemeIsDarkMode,
ThemeIsDarkGray = avatar.Settings.ThemeIsDarkGray,
ThemeIsLightGray = avatar.Settings.ThemeIsLightGray
},
UpdatedAt = avatar.UpdatedAt,
CreatedAt = avatar.CreatedAt
};
}
}