Files
SocialPub/PrivaPub/Services/ClientToServer/Private/IPrivateAvatarUsersService.cs
T
thepraandClaude Opus 5.5 d08fbabaa5 R7 custom emoji: the server's own, added by administrators, sent on posts, profiles and reactions
LocalEmoji, a new collection (unique shortcode whatever its case, never renamed: the shortcode is its id elsewhere),
managed on /clientapi/admin/emoji/list|insert|update|delete and kept in the staff's log (emoji.insert, .update,
.delete). Its picture goes through IMediaService.EmojiImage: PNG, GIF, WebP or JPEG up to 256 KB, a PNG within 256 px
with alpha kept (an animated GIF or WebP stays an animated GIF, with a still PNG), keep=none, shrunk until it fits
GoToSocial's 100 KiB, held by ServerUse "emoji:<shortcode>" so the janitor and the audit leave it. LocalEmojis keeps the
offered ones in memory, reloaded on every change: GET /api/v1/custom_emojis is real; local posts (words, title, warning,
poll options, each revision) and profiles keep the emoji they name; Notes, Questions, actors and EmojiReacts carry
Mastodon's Emoji tag with its id, served as an Emoji at /emojis/{shortcode} (410 once deleted; the path awaits the
owner's confirmation); a persona reacts with one (Pleroma's reactions, announcements). A restore keeps the live emoji
as it keeps the settings, and trashes server pictures nothing holds. tools/pasture/scenarios/emoji.sh joins batch A.

Verified: built, and run locally against a scratch database (uploads of every type, animation kept, metadata gone,
refusals, rendering, reactions, history, announcements, delete and revive, backup and restore, media audit). The
pasture scenario and the sweep were not run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-17 05:57:25 +02:00

213 lines
7.9 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;
using PrivaPub.Federation.Outbox;
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 IOutboxPublisher _outbox;
readonly DbEntities _dbEntities;
readonly IStringLocalizer<GenericRes> _localizer;
readonly ILogger<PrivateAvatarUsersService> _logger;
public PrivateAvatarUsersService(ILocalActorService localActors,
IOutboxPublisher outbox,
DbEntities dbEntities,
IStringLocalizer<GenericRes> localizer,
ILogger<PrivateAvatarUsersService> logger)
{
_localActors = localActors;
_outbox = outbox;
_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 taken.", 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,
SigningKey = Federation.Signing.IntegrityProofs.NewSeed(),
Domain = _localActors.BaseAddress
};
newAvatar.Emojis = PrivaPub.Domain.Content.LocalEmojis.OfProfile(newAvatar);
newAvatar.ID = (string)newAvatar.GenerateNewID();
if (!await _localActors.TryReserveUserName(userName, LocalActorKind.Person, newAvatar.ID, default))
return result.Invalidate(_localizer["The username '{0}' is already taken.", userName]);
if (form.Settings is { IsDefault: false })
Apply(newAvatar.Settings, 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);
// a suspended persona's actor stays as it was until the suspension is lifted (R6)
if (avatar.SuspendedAt.HasValue)
return result.Invalidate(_localizer["This persona is suspended."], StatusCodes.Status403Forbidden);
avatar.Name = form.Name;
avatar.Biography = form.Biography;
avatar.Fields = form.Fields ?? new();
avatar.PersonalNote = form.PersonalNote;
avatar.Emojis = PrivaPub.Domain.Content.LocalEmojis.OfProfile(avatar);//the server's custom emoji its profile uses (R7)
if (form.Settings != default)
Apply(avatar.Settings ??= new(), form.Settings);
avatar.UpdatedAt = DateTime.UtcNow;
await DB.Default.SaveAsync(avatar);
await _outbox.PublishProfile(_localActors.FromAvatar(avatar), default);
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();
// The client API carries only the theme; the rest (discoverable, locked, quote policy…) is set through the Mastodon API
// and must survive an edit made here.
static void Apply(AvatarSettings target, ViewAvatarSettings settings)
{
target.IsDefault = settings.IsDefault;
target.DarkThemeIndexColour = settings.DarkThemeIndexColour;
target.IconsThemeIndexColour = settings.IconsThemeIndexColour;
target.LanguageCode = settings.LanguageCode;
target.LightThemeIndexColour = settings.LightThemeIndexColour;
target.PreferSystemTheming = settings.PreferSystemTheming;
target.ThemeIsDarkGray = settings.ThemeIsDarkGray;
target.ThemeIsDarkMode = settings.ThemeIsDarkMode;
target.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
};
}
}