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 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 Administrator(CancellationToken token) { var root = await DB.Default.Find().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 List(CancellationToken token) { if (await Administrator(token) == default) return Forbid(); var emojis = await DB.Default.Find().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 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().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().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 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(); 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().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().Match(r => r.Emoji == emoji.Shortcode && r.EmojiURL == previous.Url) .Modify(r => r.EmojiURL, emoji.Url).ExecuteAsync(token); await DB.Default.Update().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 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().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 Live(string shortcode, CancellationToken token) { if (!LocalEmojis.IsShortcode(shortcode)) return default; var key = shortcode.ToLowerInvariant(); var emoji = await DB.Default.Find().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 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 }; } }