Files
SocialPub/PrivaPub/Controllers/ClientToServer/EmojiAdminController.cs
T
thepraandClaude Opus 5.5 048eb15bb7 R7's emoji id is /grimaces/{shortcode}, the owner's name
The federated id of a local custom emoji (LocalEmojis.Path) was the plan's /emojis/{shortcode} awaiting the owner; it is
/grimaces/{shortcode}, in the style of the other routes, and freezes once it deploys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-18 10:29:53 +02:00

247 lines
11 KiB
C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Admin;
using PrivaPub.Domain.Content;
using PrivaPub.Domain.Media;
using PrivaPub.Domain.Moderation;
using PrivaPub.Extensions;
using PrivaPub.Federation.Actors;
using PrivaPub.Models;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.Resources;
namespace PrivaPub.Controllers.ClientToServer
{
// The server's custom emoji from the administrator's page (completion program R7): listed, added, changed and deleted
// by an administrator, checked again against the database. Every act is kept in the staff's log (emoji.insert,
// emoji.update, emoji.delete). An emoji is never renamed: its shortcode is its id on other servers (/grimaces/{shortcode}).
[ApiController,
Route("clientapi/admin/emoji"),
Authorize(Policy = Policies.IsAdmin)]
public class EmojiAdminController : ControllerBase
{
// a picture up to a megabyte reaches EmojiImage, which refuses one over 256 KB with its reason
const long MaxRequestBytes = 1024 * 1024;
readonly IModerationLog _log;
readonly IStringLocalizer _localizer;
readonly IMediaService _media;
readonly ILocalActorService _localActors;
public EmojiAdminController(IModerationLog log, IStringLocalizer<GenericRes> localizer, IMediaService media, ILocalActorService localActors)
{
_log = log;
_localizer = localizer;
_media = media;
_localActors = localActors;
}
// the token says administrator; the database has the last word
async Task<RootUser> Administrator(CancellationToken token)
{
var root = await DB.Default.Find<RootUser>().MatchID(User.GetUserId()).ExecuteFirstAsync(token);
return root is { IsBanned: false, DeletedAt: null } && root.Policies.Contains(Policies.IsAdmin) ? root : default;
}
IActionResult Refused(int status, string message) => StatusCode(status, new WebResult().Invalidate(_localizer[message], status));
[HttpGet, Route("list")]
public async Task<IActionResult> List(CancellationToken token)
{
if (await Administrator(token) == default)
return Forbid();
var emojis = await DB.Default.Find<LocalEmoji>().Match(e => e.DeletedAt == null).Sort(e => e.Key, Order.Ascending).ExecuteAsync(token);
return Ok(emojis.Select(View).ToList());
}
// a new emoji; a shortcode deleted before comes back as it was written, with its new picture (the same id elsewhere)
[HttpPost, Route("insert"), RequestSizeLimit(MaxRequestBytes), RequestFormLimits(MultipartBodyLengthLimit = MaxRequestBytes)]
public async Task<IActionResult> Insert([FromForm] LocalEmojiForm form, CancellationToken token)
{
var admin = await Administrator(token);
if (admin == default)
return Forbid();
var key = form.Shortcode.ToLowerInvariant();
var existing = await DB.Default.Find<LocalEmoji>().Match(e => e.Key == key).ExecuteFirstAsync(token);
if (existing != default && (existing.DeletedAt == default || existing.Shortcode != form.Shortcode))
return Refused(StatusCodes.Status409Conflict, "This shortcode is taken.");
var outcome = await _media.EmojiImage(form.Shortcode, await Picture(token), token);
if (!outcome.Ok)
return StatusCode(outcome.Status, new WebResult().Invalidate(outcome.Error, outcome.Status));
var now = DateTime.UtcNow;
var emoji = existing ?? new LocalEmoji { Shortcode = form.Shortcode, Key = key, CreatedAt = now };
emoji.Category = Clean(form.Category);
emoji.VisibleInPicker = form.VisibleInPicker;
emoji.Disabled = form.Disabled;
emoji.DeletedAt = default;
emoji.UpdatedAt = now;
Picture(emoji, outcome.Attachment, now);
var saved = true;
if (existing == default)
try
{
await DB.Default.SaveAsync(emoji, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
saved = false;//the same shortcode added at the same moment
}
else
saved = (await DB.Default.Update<LocalEmoji>().Match(e => e.ID == existing.ID && e.DeletedAt != null).ModifyWith(emoji).ExecuteAsync(token))
.ModifiedCount > 0;
if (!saved)
{
await _media.Trash(m => m.ID == outcome.Attachment.ID, "emoji not saved", token);
return Refused(StatusCodes.Status409Conflict, "This shortcode is taken.");
}
await LocalEmojis.Reload(CancellationToken.None);
var notes = new[] { existing != default ? "added again" : default, emoji.Disabled ? "disabled" : default, Animated(emoji) }.Where(n => n != default).ToList();
await _log.Write(admin.ID, AdminActions.EmojiInsert, AdminTargets.Emoji, emoji.ID, $":{emoji.Shortcode}:",
note: notes.Count > 0 ? string.Join(", ", notes) : default, token: token);
return Ok(View(emoji));
}
// the emoji with that shortcode as it should be now: its category, the picker, disabled or not, and a new picture if
// one is sent
[HttpPost, Route("update"), RequestSizeLimit(MaxRequestBytes), RequestFormLimits(MultipartBodyLengthLimit = MaxRequestBytes)]
public async Task<IActionResult> Update([FromForm] LocalEmojiForm form, CancellationToken token)
{
var admin = await Administrator(token);
if (admin == default)
return Forbid();
var emoji = await Live(form.Shortcode, token);
if (emoji == default)
return NotFound();
var changed = new List<string>();
var category = Clean(form.Category);
if (emoji.Category != category)
changed.Add("category");
if (emoji.VisibleInPicker != form.VisibleInPicker)
changed.Add(form.VisibleInPicker ? "in the picker" : "out of the picker");
if (emoji.Disabled != form.Disabled)
changed.Add(form.Disabled ? "disabled" : "enabled");
emoji.Category = category;
emoji.VisibleInPicker = form.VisibleInPicker;
emoji.Disabled = form.Disabled;
var image = await Picture(token);
var previous = (MediaId: emoji.MediaId, Url: emoji.Url);
var now = DateTime.UtcNow;
MediaOutcome outcome = default;
if (image != default)
{
outcome = await _media.EmojiImage(emoji.Shortcode, image, token);
if (!outcome.Ok)
return StatusCode(outcome.Status, new WebResult().Invalidate(outcome.Error, outcome.Status));
Picture(emoji, outcome.Attachment, now);
changed.Add("image");
}
if (changed.Count == 0)
return Ok(View(emoji));
emoji.UpdatedAt = now;
if ((await DB.Default.Update<LocalEmoji>().Match(e => e.ID == emoji.ID && e.DeletedAt == null).ModifyWith(emoji).ExecuteAsync(token)).ModifiedCount == 0)
{
if (outcome != default)
await _media.Trash(m => m.ID == outcome.Attachment.ID, "emoji not saved", token);
return NotFound();//deleted meanwhile
}
if (outcome != default)
{
var use = LocalEmojis.ServerUse(emoji.Shortcode);
if (previous.MediaId != default)
await _media.Trash(m => m.ID == previous.MediaId && m.ServerUse == use, "emoji image replaced", token);
// the reactions made with it show the picture it has now
if (previous.Url != default)
{
await DB.Default.Update<Reaction>().Match(r => r.Emoji == emoji.Shortcode && r.EmojiURL == previous.Url)
.Modify(r => r.EmojiURL, emoji.Url).ExecuteAsync(token);
await DB.Default.Update<Notification>().Match(n => n.Emoji == emoji.Shortcode && n.EmojiURL == previous.Url)
.Modify(n => n.EmojiURL, emoji.Url).ExecuteAsync(token);
}
}
await LocalEmojis.Reload(CancellationToken.None);
await _log.Write(admin.ID, AdminActions.EmojiUpdate, AdminTargets.Emoji, emoji.ID, $":{emoji.Shortcode}:", note: string.Join(", ", changed), token: token);
return Ok(View(emoji));
}
// deleted: offered nowhere, left out of what posts and profiles render, its picture trashed, and its id answers 410.
// No other server is told (Mastodon tells none either); they keep their copy until they read a post again.
[HttpPost, Route("delete")]
public async Task<IActionResult> Delete([FromQuery] string shortcode, CancellationToken token)
{
var admin = await Administrator(token);
if (admin == default)
return Forbid();
var emoji = await Live(shortcode, token);
if (emoji == default)
return NotFound();
var now = DateTime.UtcNow;
if ((await DB.Default.Update<LocalEmoji>().Match(e => e.ID == emoji.ID && e.DeletedAt == null)
.Modify(e => e.DeletedAt, now).Modify(e => e.UpdatedAt, now).ExecuteAsync(token)).ModifiedCount == 0)
return NotFound();
var use = LocalEmojis.ServerUse(emoji.Shortcode);
await _media.Trash(m => m.ID == emoji.MediaId && m.ServerUse == use, "emoji deleted", token);
await LocalEmojis.Reload(CancellationToken.None);
await _log.Write(admin.ID, AdminActions.EmojiDelete, AdminTargets.Emoji, emoji.ID, $":{emoji.Shortcode}:", token: token);
return Ok();
}
static async Task<LocalEmoji> Live(string shortcode, CancellationToken token)
{
if (!LocalEmojis.IsShortcode(shortcode))
return default;
var key = shortcode.ToLowerInvariant();
var emoji = await DB.Default.Find<LocalEmoji>().Match(e => e.Key == key && e.DeletedAt == null).ExecuteFirstAsync(token);
return emoji?.Shortcode == shortcode ? emoji : default;
}
// the picture sent as `image` (or `file`, as the thumbnail's route names it)
async Task<IFormFile> Picture(CancellationToken token) =>
Request.HasFormContentType && await Request.ReadFormAsync(token) is { } form ? form.Files["image"] ?? form.Files["file"] : default;
void Picture(LocalEmoji emoji, Models.Media.MediaAttachment attachment, DateTime now)
{
emoji.MediaId = attachment.ID;
emoji.Url = _media.Url(attachment.FilePath);
emoji.StaticUrl = attachment.PreviewPath == default ? emoji.Url : _media.Url(attachment.PreviewPath);
emoji.ContentType = attachment.ContentType;
emoji.Width = attachment.Width;
emoji.Height = attachment.Height;
emoji.Size = attachment.Size;
emoji.ImageUpdatedAt = now;
}
static string Animated(LocalEmoji emoji) => emoji.ContentType == "image/gif" ? "animated" : default;
static string Clean(string text) => string.IsNullOrWhiteSpace(text) ? default : text.Trim();
ViewLocalEmoji View(LocalEmoji emoji) => new()
{
Id = emoji.ID,
Shortcode = emoji.Shortcode,
Category = emoji.Category,
VisibleInPicker = emoji.VisibleInPicker,
Disabled = emoji.Disabled,
Url = emoji.Url,
StaticUrl = emoji.StaticUrl ?? emoji.Url,
ContentType = emoji.ContentType,
Width = emoji.Width,
Height = emoji.Height,
Size = emoji.Size,
Uri = LocalEmojis.Uri(_localActors.BaseAddress, emoji.Shortcode),
CreatedAt = emoji.CreatedAt,
UpdatedAt = emoji.UpdatedAt,
ImageUpdatedAt = emoji.ImageUpdatedAt
};
}
}