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
247 lines
11 KiB
C#
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 (/emojis/{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
|
|
};
|
|
}
|
|
}
|