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
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 (/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
|
|
};
|
|
}
|
|
}
|