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
This commit is contained in:
1 parent
9f68ca3ed0
commit
d08fbabaa5
38 files changed
+1117
-54
No files matched your search
@@ -115,6 +115,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
}
|
||||
if (fields.Count > 0 || Params.Has("fields_attributes[0][name]"))
|
||||
avatar.Fields = fields;
|
||||
avatar.Emojis = PrivaPub.Domain.Content.LocalEmojis.OfProfile(avatar);//the server's custom emoji its name, note and fields use (R7)
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
await DB.Default.SaveAsync(avatar, token);
|
||||
foreach (var made in replaced)
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Domain.Content;
|
||||
using PrivaPub.Domain.Social;
|
||||
using PrivaPub.Models.Social;
|
||||
|
||||
@@ -65,7 +66,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
["mentions"] = Array.Empty<object>(),
|
||||
["statuses"] = Array.Empty<object>(),
|
||||
["tags"] = Array.Empty<object>(),
|
||||
["emojis"] = Array.Empty<object>(),
|
||||
["emojis"] = Mappers.MastodonMapper.LocalEmojiEntities(LocalEmojis.In(a.Text)),//the server's custom emoji its text uses (R7)
|
||||
["reactions"] = reactions.Where(r => r.AnnouncementId == a.ID)
|
||||
.GroupBy(r => r.Name)
|
||||
.OrderBy(g => g.Min(r => r.CreatedAt))
|
||||
@@ -78,12 +79,18 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
// a unicode emoji's reaction: no url (custom emoji come later); `me` only for a persona
|
||||
// a reaction: a unicode emoji's has no url, one of the server's custom emoji's has its pictures (R7); `me` only for a
|
||||
// persona
|
||||
static Dictionary<string, object> Reaction(string name, int count, bool? me)
|
||||
{
|
||||
var reaction = new Dictionary<string, object> { ["name"] = name, ["count"] = count };
|
||||
if (me is { } mine)
|
||||
reaction["me"] = mine;
|
||||
if (LocalEmojis.Find(name) is { } custom)
|
||||
{
|
||||
reaction["url"] = custom.Url;
|
||||
reaction["static_url"] = custom.StaticUrl;
|
||||
}
|
||||
return reaction;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Entities;
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Domain.Content;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
// Mastodon's list of the server's custom emoji (completion program R7), for clients' pickers: as Mastodon lists them,
|
||||
// those offered (not disabled) and shown in the picker, by shortcode. Read from LocalEmojis, the copy in memory that every
|
||||
// change reloads at once (and that is read again when five minutes old), so no request reads the database.
|
||||
public class CustomEmojisController : MastodonController
|
||||
{
|
||||
[HttpGet("/api/v1/custom_emojis"), AllowAnonymous]
|
||||
public IActionResult List() => Json(LocalEmojis.Current.Values
|
||||
.Where(e => e.VisibleInPicker)
|
||||
.OrderBy(e => e.Shortcode, StringComparer.Ordinal)
|
||||
.Select(e => new CustomEmojiEntity { Shortcode = e.Shortcode, Url = e.Url, StaticUrl = e.StaticUrl, VisibleInPicker = true, Category = e.Category })
|
||||
.ToList());
|
||||
}
|
||||
}
|
||||
@@ -140,9 +140,6 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
|
||||
public class StubsController : MastodonController
|
||||
{
|
||||
[HttpGet("/api/v1/custom_emojis"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult CustomEmojis() => Json(Array.Empty<object>());
|
||||
|
||||
[HttpGet("/api/v1/suggestions"), Scope("read")]
|
||||
public IActionResult SuggestionsV1() => Json(Array.Empty<object>());
|
||||
|
||||
|
||||
@@ -291,7 +291,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
SpoilerText = r.SpoilerText ?? string.Empty,
|
||||
Sensitive = r.HasContentWarning,
|
||||
CreatedAt = MastodonJson.Time(r.EditedAt),
|
||||
Account = account
|
||||
Account = account,
|
||||
Emojis = _mapper.EmojisOf(post, r.Emojis, r.ContentHtml, r.SpoilerText, r.Title)
|
||||
}).ToList();
|
||||
edits.Add(new StatusEdit
|
||||
{
|
||||
@@ -299,7 +300,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
SpoilerText = post.SpoilerText ?? string.Empty,
|
||||
Sensitive = post.HasContentWarning,
|
||||
CreatedAt = MastodonJson.Time(post.EditedAt ?? post.CreationDate),
|
||||
Account = account
|
||||
Account = account,
|
||||
Emojis = _mapper.EmojisOf(post, post.Emojis, post.ContentHtml, post.Text, post.SpoilerText, post.Title)
|
||||
});
|
||||
return Json(edits);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using PrivaPub.Api.Mastodon.Entities;
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Domain.Content;
|
||||
using PrivaPub.Domain.Media;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
@@ -123,6 +124,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
FollowingCount = (int)following,
|
||||
StatusesCount = (int)statuses,
|
||||
LastStatusAt = latest == default ? default : latest.CreationDate.ToString("yyyy-MM-dd"),
|
||||
Emojis = LocalEmojiEntities(actor.Emojis),
|
||||
Fields = actor.Fields.Select(f => new Field { Name = f.Key, Value = ActivityPubRenderer.FieldValue(f.Value) }).ToList()
|
||||
};
|
||||
if (withSource)
|
||||
@@ -303,7 +305,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
MediaAttachments = post.Media.Count > 0 ? post.Media.Select(Media).ToList() : Playable(post),
|
||||
Card = Card(post),
|
||||
Poll = Poll(post, viewerId, ownVotes.GetValueOrDefault(post.ID)),
|
||||
Emojis = Emojis(post.Emojis),
|
||||
Emojis = post.IsFederatedCopy ? Emojis(post.Emojis) : LocalEmojiEntities(post.Emojis),
|
||||
Privapub = Extension(post, accounts, viewerId == default ? default(bool?) : downvoted.Contains(post.ID)),
|
||||
EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new(),
|
||||
Pleroma = new PleromaStatus { EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new() },
|
||||
@@ -500,13 +502,31 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
Voted = viewerId == default ? default(bool?) : ownVotes?.Count > 0 || post.GroupUserId == viewerId,
|
||||
OwnVotes = viewerId == default ? default : ownVotes?.ToList() ?? new List<int>(),
|
||||
Options = poll.Options.Select(o => new PollOptionEntity { Title = o.Title, VotesCount = hidden ? default(int?) : o.Votes }).ToList(),
|
||||
Emojis = Emojis(post.Emojis.Where(e => poll.Options.Any(o => o.Title.Contains($":{e.Shortcode}:"))))
|
||||
Emojis = post.IsFederatedCopy
|
||||
? Emojis(post.Emojis.Where(e => poll.Options.Any(o => o.Title.Contains($":{e.Shortcode}:"))))
|
||||
: LocalEmojiEntities(post.Emojis.Where(e => poll.Options.Any(o => o.Title.Contains($":{e.Shortcode}:"))))
|
||||
};
|
||||
}
|
||||
|
||||
List<CustomEmojiEntity> Emojis(IEnumerable<CustomEmoji> emojis) =>
|
||||
emojis?.Select(e => new CustomEmojiEntity { Shortcode = e.Shortcode, Url = Proxied(e.URL), StaticUrl = Proxied(e.URL) }).ToList() ?? new();
|
||||
|
||||
/// <summary>The custom emoji one version of a post names (its history's `emojis`): those the version kept, else the
|
||||
/// post's that its texts name.</summary>
|
||||
public List<CustomEmojiEntity> EmojisOf(PostEntity post, IReadOnlyCollection<CustomEmoji> kept, params string[] texts)
|
||||
{
|
||||
var named = (kept is { Count: > 0 } ? kept : post.Emojis ?? new List<CustomEmoji>()).Where(e => texts.Any(t => t?.Contains($":{e.Shortcode}:") == true));
|
||||
return post.IsFederatedCopy ? Emojis(named) : LocalEmojiEntities(named);
|
||||
}
|
||||
|
||||
// a local post's or persona's emoji are this server's, as they are now: one disabled or deleted since is left out, and
|
||||
// a new picture shows (R7)
|
||||
public static List<CustomEmojiEntity> LocalEmojiEntities(IEnumerable<CustomEmoji> kept) =>
|
||||
LocalEmojis.Of(kept).Select(e => new CustomEmojiEntity
|
||||
{
|
||||
Shortcode = e.Shortcode, Url = e.Url, StaticUrl = e.StaticUrl, VisibleInPicker = e.VisibleInPicker, Category = e.Category
|
||||
}).ToList();
|
||||
|
||||
PrivaPubStatus Extension(PostEntity post, Dictionary<string, Account> accounts, bool? downvoted) => new()
|
||||
{
|
||||
Wall = post.WallOwnerAccountId == default || post.WallOwnerAccountId == AuthorOf(post) ? default : accounts.GetValueOrDefault(post.WallOwnerAccountId),
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Models.Post;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace PrivaPub.Domain.Content
|
||||
{
|
||||
// This server's own custom emoji (completion program R7), in memory: the ones offered now (neither disabled nor deleted),
|
||||
// for the places that read them one text at a time (a post published or edited, a profile saved, a Note or an actor
|
||||
// rendered, a reaction, the Mastodon API's lists). Read at start, again after every change an administrator makes
|
||||
// (EmojiAdminController) and when five minutes old; a process that never loaded it starts with none and loads it on
|
||||
// first use.
|
||||
//
|
||||
// A local post or profile keeps the emoji its text named when it was written (Post.Emojis, Avatar.Emojis); what is
|
||||
// rendered of them is what they are now: one disabled or deleted since is left out, and one whose picture changed is
|
||||
// shown with the new one, its `updated` telling the servers that hold the old one to fetch it again.
|
||||
public static class LocalEmojis
|
||||
{
|
||||
/// <summary>Where each emoji is served as an ActivityPub `Emoji` object: /emojis/{shortcode}, its id on other servers.
|
||||
/// The plan's name, waiting for the owner's confirmation; it is frozen once it has federated (CLAUDE.md, routes).</summary>
|
||||
public const string Path = "emojis";
|
||||
public const int MaxShortcodeLength = 64;
|
||||
public const int MaxPerObject = 64;//what ObjectShapes reads of anyone's object
|
||||
const string ServerUsePrefix = "emoji:";
|
||||
|
||||
static readonly TimeSpan Staleness = TimeSpan.FromMinutes(5);
|
||||
static readonly Regex Shortcode = new("^[a-zA-Z0-9_]{2,64}$", RegexOptions.CultureInvariant);
|
||||
// `:name:` with no letter or digit right before or after it, as Mastodon scans texts (`:a::b:` is two)
|
||||
static readonly Regex Named = new(@"(?<![a-zA-Z0-9]):([a-zA-Z0-9_]{2,64}):(?![a-zA-Z0-9])", RegexOptions.CultureInvariant);
|
||||
|
||||
public sealed record Live(string Id, string Shortcode, string Url, string StaticUrl, string ContentType, DateTime ImageUpdatedAt, string Category,
|
||||
bool VisibleInPicker);
|
||||
|
||||
static volatile IReadOnlyDictionary<string, Live> _current = new Dictionary<string, Live>(StringComparer.Ordinal);
|
||||
static DateTime _loadedAt = DateTime.MinValue;
|
||||
static int _reloading;
|
||||
|
||||
/// <summary>The emoji offered now, by their exact shortcode.</summary>
|
||||
public static IReadOnlyDictionary<string, Live> Current
|
||||
{
|
||||
get
|
||||
{
|
||||
RefreshIfStale();
|
||||
return _current;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsShortcode(string text) => !string.IsNullOrEmpty(text) && Shortcode.IsMatch(text);
|
||||
|
||||
/// <summary>The id an emoji federates under: {base}/emojis/{shortcode}.</summary>
|
||||
public static string Uri(string baseAddress, string shortcode) => $"{baseAddress}/{Path}/{shortcode}";
|
||||
|
||||
/// <summary>What holds its picture (MediaAttachment.ServerUse): the janitor and the media audit leave such rows alone.</summary>
|
||||
public static string ServerUse(string shortcode) => ServerUsePrefix + shortcode;
|
||||
|
||||
public static bool IsEmojiUse(string serverUse) => serverUse?.StartsWith(ServerUsePrefix, StringComparison.Ordinal) == true;
|
||||
|
||||
public static Live Find(string shortcode) =>
|
||||
shortcode != default && Current.TryGetValue(shortcode, out var emoji) ? emoji : default;
|
||||
|
||||
public static async Task Reload(CancellationToken token = default)
|
||||
{
|
||||
var offered = await DB.Default.Find<LocalEmoji>().Match(e => e.DeletedAt == null && !e.Disabled).ExecuteAsync(token);
|
||||
_current = offered.Where(e => IsShortcode(e.Shortcode) && !string.IsNullOrEmpty(e.Url))
|
||||
.GroupBy(e => e.Shortcode, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.Select(e => new Live(e.ID, e.Shortcode, e.Url, e.StaticUrl ?? e.Url, e.ContentType ?? "image/png", e.ImageUpdatedAt,
|
||||
e.Category, e.VisibleInPicker)).First(), StringComparer.Ordinal);
|
||||
_loadedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
static void RefreshIfStale()
|
||||
{
|
||||
if (DateTime.UtcNow - _loadedAt < Staleness || Interlocked.Exchange(ref _reloading, 1) == 1)
|
||||
return;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await Reload(CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.ForContext(typeof(LocalEmojis)).Warning(ex, "The custom emoji could not be reloaded");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _reloading, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>The emoji offered now that the texts name as `:shortcode:`, as a post or a profile keeps them; remote
|
||||
/// shortcodes are not ours and are never taken.</summary>
|
||||
public static List<CustomEmoji> In(IEnumerable<string> texts)
|
||||
{
|
||||
var current = Current;
|
||||
if (current.Count == 0)
|
||||
return new List<CustomEmoji>();
|
||||
return texts.Where(t => !string.IsNullOrEmpty(t))
|
||||
.SelectMany(t => Named.Matches(t).Select(m => m.Groups[1].Value))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Select(code => current.GetValueOrDefault(code))
|
||||
.Where(e => e != default)
|
||||
.Take(MaxPerObject)
|
||||
.Select(e => new CustomEmoji { Shortcode = e.Shortcode, URL = e.Url, Updated = e.ImageUpdatedAt })
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static List<CustomEmoji> In(params string[] texts) => In((IEnumerable<string>)texts);
|
||||
|
||||
/// <summary>A persona's emoji: those its name, its bio and its fields name.</summary>
|
||||
public static List<CustomEmoji> OfProfile(Models.User.Avatar avatar) =>
|
||||
In(new[] { avatar.Name, avatar.Biography }.Concat((avatar.Fields ?? new()).SelectMany(f => new[] { f.Key, f.Value })));
|
||||
|
||||
/// <summary>What a local post's or persona's kept emoji are now: those still offered, as they are now.</summary>
|
||||
public static List<Live> Of(IEnumerable<CustomEmoji> kept)
|
||||
{
|
||||
var current = Current;
|
||||
if (kept == default || current.Count == 0)
|
||||
return new List<Live>();
|
||||
return kept.Where(e => e?.Shortcode != default).Select(e => current.GetValueOrDefault(e.Shortcode)).Where(e => e != default)
|
||||
.DistinctBy(e => e.Shortcode).Take(MaxPerObject).ToList();
|
||||
}
|
||||
|
||||
/// <summary>The local emoji a reaction names: `:shortcode:`, `:shortcode@this-host:` or the bare shortcode (Mastodon's
|
||||
/// announcements, Pleroma's clients); none for a unicode emoji or another server's.</summary>
|
||||
public static Live ForReaction(string content, string domain)
|
||||
{
|
||||
var text = content?.Trim();
|
||||
if (string.IsNullOrEmpty(text) || text.Length > 300)
|
||||
return default;
|
||||
if (text.Length > 2 && text.StartsWith(':') && text.EndsWith(':'))
|
||||
text = text[1..^1];
|
||||
var at = text.IndexOf('@');
|
||||
if (at >= 0)
|
||||
{
|
||||
if (!string.Equals(text[(at + 1)..], domain, StringComparison.OrdinalIgnoreCase))
|
||||
return default;
|
||||
text = text[..at];
|
||||
}
|
||||
return IsShortcode(text) ? Find(text) : default;
|
||||
}
|
||||
|
||||
/// <summary>An emoji as a tag of a Note, Question, actor or EmojiReact, in Mastodon's shape (GoToSocial needs its id).</summary>
|
||||
public static JsonObject Tag(Live emoji, string baseAddress) => Build(emoji, baseAddress, new JsonObject());
|
||||
|
||||
/// <summary>The document served at the emoji's id: the same object, with the context.</summary>
|
||||
public static JsonObject Document(Live emoji, string baseAddress) =>
|
||||
Build(emoji, baseAddress, new JsonObject { ["@context"] = ActivityPubRenderer.Context() });
|
||||
|
||||
static JsonObject Build(Live emoji, string baseAddress, JsonObject into)
|
||||
{
|
||||
into["id"] = Uri(baseAddress, emoji.Shortcode);
|
||||
into["type"] = "Emoji";
|
||||
into["name"] = $":{emoji.Shortcode}:";
|
||||
into["updated"] = ActivityPubRenderer.Timestamp(emoji.ImageUpdatedAt);
|
||||
into["icon"] = new JsonObject { ["type"] = "Image", ["mediaType"] = emoji.ContentType, ["url"] = emoji.Url };
|
||||
return into;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ namespace PrivaPub.Domain.Media
|
||||
// What the media directory holds against what the database says holds it, and, with fix, the two made to agree:
|
||||
// - a persona's current avatar or header with no row (they had none before) becomes a row of its own;
|
||||
// - media of a deleted post or of a deleted persona go to the trash, and a picture of the server's own (ServerUse) that
|
||||
// its settings no longer name;
|
||||
// its settings no longer name (its thumbnail) or no custom emoji holds;
|
||||
// - rows whose files are gone go to the trash;
|
||||
// - files nothing holds (deleted posts' media and replaced pictures, from before media were trashed) get a trashed row
|
||||
// each.
|
||||
@@ -60,9 +60,12 @@ namespace PrivaPub.Domain.Media
|
||||
var ownerIds = rows.Select(r => r.OwnerAvatarId).Where(id => id != null).Distinct().ToList();
|
||||
var liveOwners = (await DB.Default.Find<Avatar>().Match(a => ownerIds.Contains(a.ID) && !a.DeletionAt.HasValue).Project(a => a.Include(x => x.ID)).ExecuteAsync(token))
|
||||
.Select(a => a.ID).ToHashSet();
|
||||
// (the server's own pictures belong to nobody: they are held while the settings name them)
|
||||
// (the server's own pictures belong to nobody: they are held while the settings name them, or a custom emoji that is
|
||||
// not deleted, disabled ones too)
|
||||
var settings = await DB.Default.Find<Models.ServerSettings>().Match(x => x.ID == Models.ServerSettings.Name).ExecuteFirstAsync(token);
|
||||
var serverHeld = new HashSet<string> { settings?.ThumbnailMediaId };
|
||||
serverHeld.UnionWith((await DB.Default.Find<Models.Post.LocalEmoji>().Match(e => e.DeletedAt == null).Project(e => e.Include(x => x.MediaId))
|
||||
.ExecuteAsync(token)).Select(e => e.MediaId).Where(id => id != null));
|
||||
var ofDeleted = rows.Where(r => r.ServerUse != null ? !serverHeld.Contains(r.ID)
|
||||
: (r.PostId != null && !livePosts.Contains(r.PostId)) || (r.OwnerAvatarId != null && !liveOwners.Contains(r.OwnerAvatarId))).ToList();
|
||||
var missing = rows.Except(ofDeleted).Where(r => !string.IsNullOrEmpty(r.FilePath) && !files.Contains(r.FilePath)).ToList();
|
||||
|
||||
@@ -49,6 +49,10 @@ namespace PrivaPub.Domain.Media
|
||||
/// <summary>A picture of the server's own (its thumbnail, "thumbnail"), fitted within 1200×630, as a row nobody owns,
|
||||
/// held by its <see cref="MediaAttachment.ServerUse"/>.</summary>
|
||||
Task<MediaOutcome> ServerImage(string use, IFormFile file, CancellationToken token);
|
||||
/// <summary>A custom emoji's picture (R7): a PNG at most 256 px on its longer side, alpha kept, or an animated GIF
|
||||
/// that stays one, small enough for every peer (EmojiMaxBytes); a row nobody owns, held by ServerUse
|
||||
/// "emoji:<shortcode>".</summary>
|
||||
Task<MediaOutcome> EmojiImage(string shortcode, IFormFile file, CancellationToken token);
|
||||
/// <summary>Trashes the media <paramref name="which"/> matches (and that isn't trashed yet): each row is marked in one
|
||||
/// conditional update, so a row attached meanwhile is left alone, and its files stop being served at once.</summary>
|
||||
Task<long> Trash(System.Linq.Expressions.Expression<Func<MediaAttachment, bool>> which, string reason, CancellationToken token);
|
||||
@@ -299,6 +303,108 @@ namespace PrivaPub.Domain.Media
|
||||
return new MediaOutcome(attachment);
|
||||
}
|
||||
|
||||
// what an emoji's picture may be sent as, and how big (Mastodon's limit for one, 256 KB)
|
||||
static readonly string[] EmojiTypes = { "image/png", "image/gif", "image/webp", "image/jpeg" };
|
||||
public const long EmojiMaxUploadBytes = 256 * 1024;
|
||||
// what it may be once made: GoToSocial's default for another server's emoji (media-emoji-remote-max-size, 100 KiB) is
|
||||
// the smallest any peer takes; Mastodon's is 256 KB
|
||||
public const long EmojiMaxBytes = 100 * 1024;
|
||||
public const int EmojiSide = 256;
|
||||
// tried in turn until the picture fits EmojiMaxBytes: 256 px as it is, then with a palette, then fewer pixels
|
||||
static readonly (int Side, bool Palette)[] EmojiAttempts = { (256, false), (256, true), (192, true), (128, true), (96, true), (64, true) };
|
||||
|
||||
// As safe as any upload (checked before it is decoded, the allowed loaders alone, written without its metadata).
|
||||
// A still picture becomes a PNG, its transparency kept; an animated GIF or WebP becomes an animated GIF, since a PNG
|
||||
// would lose the animation and GIF is the animated picture every peer shows as an emoji, with a still PNG of its first
|
||||
// frame for static_url.
|
||||
public async Task<MediaOutcome> EmojiImage(string shortcode, IFormFile file, CancellationToken token)
|
||||
{
|
||||
var contentType = file?.ContentType?.Split(';')[0].Trim().ToLowerInvariant();
|
||||
if (file == default || file.Length == 0)
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Image can't be blank");
|
||||
if (!EmojiTypes.Contains(contentType))
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: An emoji's image is a PNG, GIF, WebP or JPEG");
|
||||
if (file.Length > EmojiMaxUploadBytes)
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: An emoji's image is at most 256 KB");
|
||||
var input = await Read(file, token);
|
||||
if (Refusal(input, _options.CurrentValue) is { } refused)
|
||||
return refused;
|
||||
byte[] bytes, still = default;
|
||||
int width, height;
|
||||
bool animated;
|
||||
await _processing.WaitAsync(token);
|
||||
try
|
||||
{
|
||||
var header = Inspect(input);
|
||||
animated = header.Pages > 1 && (header.Loader?.StartsWith("gifload", StringComparison.Ordinal) == true
|
||||
|| header.Loader?.StartsWith("webpload", StringComparison.Ordinal) == true);
|
||||
(bytes, still, width, height) = animated ? EmojiAnimation(input) : EmojiStill(input);
|
||||
}
|
||||
catch (VipsException ex)
|
||||
{
|
||||
_logger.LogInformation("Refused an emoji's picture: {Error}", ex.Message);
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The file is not a readable image");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_processing.Release();
|
||||
}
|
||||
if (bytes == default)
|
||||
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The image can't be made small enough for an emoji (100 KB)");
|
||||
var attachment = new MediaAttachment
|
||||
{
|
||||
Kind = "emoji",
|
||||
ServerUse = Content.LocalEmojis.ServerUse(shortcode),
|
||||
ContentType = animated ? "image/gif" : "image/png",
|
||||
FilePath = await Save(bytes, animated ? "gif" : "png", token),
|
||||
PreviewPath = still == default ? default : await Save(still, "png", token),
|
||||
Size = bytes.Length,
|
||||
Width = width,
|
||||
Height = height,
|
||||
AttachedAt = DateTime.UtcNow
|
||||
};
|
||||
await DB.Default.SaveAsync(attachment, token);
|
||||
return new MediaOutcome(attachment);
|
||||
}
|
||||
|
||||
static (byte[] Bytes, byte[] Still, int Width, int Height) EmojiStill(byte[] input)
|
||||
{
|
||||
foreach (var (side, palette) in EmojiAttempts)
|
||||
{
|
||||
using var image = Image.ThumbnailBuffer(input, side, height: side, size: Enums.Size.Down);
|
||||
using var pixels = EmojiPixels(image);
|
||||
var bytes = pixels.WriteToBuffer(palette ? ".png[keep=none,compression=9,palette=true]" : ".png[keep=none,compression=9]");
|
||||
if (bytes.Length <= EmojiMaxBytes)
|
||||
return (bytes, default, pixels.Width, pixels.Height);
|
||||
}
|
||||
return default;
|
||||
}
|
||||
|
||||
// every frame shrunk to the side (thumbnail with n=-1 resizes each page), written as a GIF; its first frame as a PNG
|
||||
static (byte[] Bytes, byte[] Still, int Width, int Height) EmojiAnimation(byte[] input)
|
||||
{
|
||||
foreach (var side in EmojiAttempts.Select(a => a.Side).Distinct())
|
||||
{
|
||||
using var frames = Image.ThumbnailBuffer(input, side, height: side, optionString: "n=-1", size: Enums.Size.Down);
|
||||
using var pixels = EmojiPixels(frames);
|
||||
var bytes = pixels.WriteToBuffer(".gif[keep=none]");
|
||||
if (bytes.Length > EmojiMaxBytes)
|
||||
continue;
|
||||
using var first = Image.ThumbnailBuffer(input, side, height: side, size: Enums.Size.Down);
|
||||
using var still = EmojiPixels(first);
|
||||
var height = pixels.Contains("page-height") ? (int)pixels.Get("page-height") : pixels.Height;
|
||||
return (bytes, still.WriteToBuffer(".png[keep=none,compression=9,palette=true]"), pixels.Width, height);
|
||||
}
|
||||
return default;
|
||||
}
|
||||
|
||||
// 8-bit sRGB, its alpha kept (thumbnail already brought its colours into sRGB; a 16-bit or grey picture is made so)
|
||||
static Image EmojiPixels(Image image)
|
||||
{
|
||||
using var srgb = image.Interpretation == Enums.Interpretation.Srgb ? image.Copy() : image.Colourspace(Enums.Interpretation.Srgb);
|
||||
return srgb.Format == Enums.BandFormat.Uchar ? srgb.Copy() : srgb.Cast(Enums.BandFormat.Uchar);
|
||||
}
|
||||
|
||||
// a login over its quota (Media:QuotaBytesPerRoot; none when 0) uploads nothing more
|
||||
async Task<MediaOutcome> OverQuota(string avatarId, long incoming, CancellationToken token)
|
||||
{
|
||||
|
||||
@@ -367,6 +367,9 @@ namespace PrivaPub.Domain.Portability
|
||||
}
|
||||
if (Text(note["type"]) == "Question")
|
||||
post.Poll = Poll(note);
|
||||
// this server's custom emoji the words name, as a post written here gets them (R7)
|
||||
post.Emojis = Content.LocalEmojis.In(new[] { post.Text ?? post.ContentHtml, post.Title, post.SpoilerText }
|
||||
.Concat(post.Poll?.Options.Select(o => o.Title) ?? Enumerable.Empty<string>()));
|
||||
// the conversation (FEP-7888) of its thread's first post, when the thread starts in the archive
|
||||
if (visibility is PostVisibility.Public or PostVisibility.Unlisted && Root(run, original) is { } root && run.Posts.TryGetValue(root, out var first))
|
||||
post.ContextURI = first.Uri + "/context";
|
||||
@@ -812,6 +815,7 @@ namespace PrivaPub.Domain.Portability
|
||||
.Take(4).ToDictionary(f => Text(f["name"]).Trim(), f => Plain(Text(f["value"]) ?? string.Empty));
|
||||
if (fields.Count > 0)
|
||||
avatar.Fields = fields;
|
||||
avatar.Emojis = Content.LocalEmojis.OfProfile(avatar);//this server's custom emoji the profile names (R7)
|
||||
if (actor["manuallyApprovesFollowers"]?.GetValueKind() is { } locked and (System.Text.Json.JsonValueKind.True or System.Text.Json.JsonValueKind.False))
|
||||
avatar.Settings.IsLocked = locked == System.Text.Json.JsonValueKind.True;
|
||||
if (actor["discoverable"]?.GetValueKind() is { } discoverable and (System.Text.Json.JsonValueKind.True or System.Text.Json.JsonValueKind.False))
|
||||
|
||||
@@ -73,11 +73,17 @@ namespace PrivaPub.Domain.Social
|
||||
{
|
||||
if (await FindShown(id, token) == default)
|
||||
return (StatusCodes.Status404NotFound, "Record not found");
|
||||
// one unicode emoji (custom emoji come with R7): the same normalisation as a post's reactions
|
||||
var picked = Reactions.Normalise(name, new List<Models.Post.CustomEmoji>(), requireKnownCustom: true);
|
||||
if (picked == default || picked.Url != default || picked.Name.Length > MaxNameLength)
|
||||
return (StatusCodes.Status422UnprocessableEntity, "Validation failed: Name is not a recognized emoji");
|
||||
name = picked.Name;
|
||||
// one unicode emoji, normalised as a post's reactions are, or one of the server's custom emoji that is offered, by its
|
||||
// shortcode (R7), as Mastodon takes them
|
||||
if (LocalEmojis.ForReaction(name, default) is { } custom)
|
||||
name = custom.Shortcode;
|
||||
else
|
||||
{
|
||||
var picked = Reactions.Normalise(name, new List<Models.Post.CustomEmoji>(), requireKnownCustom: true);
|
||||
if (picked == default || picked.Url != default || picked.Name.Length > MaxNameLength)
|
||||
return (StatusCodes.Status422UnprocessableEntity, "Validation failed: Name is not a recognized emoji");
|
||||
name = picked.Name;
|
||||
}
|
||||
if (!add)
|
||||
{
|
||||
var removed = await DB.Default.DeleteAsync<AnnouncementReaction>(r => r.AnnouncementId == id && r.AvatarId == avatarId && r.Name == name);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Domain.Content;
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Inbox;
|
||||
@@ -84,13 +85,20 @@ namespace PrivaPub.Domain.Social
|
||||
return true;
|
||||
}
|
||||
|
||||
// a unicode emoji, one of this server's custom emoji that is offered (`:code:`, as Akkoma names its own; R7), or a custom
|
||||
// emoji the post or its reactions already show (`:code@host:` for another server's)
|
||||
public async Task<bool> React(LocalActor me, PostEntity post, string emoji, CancellationToken token)
|
||||
{
|
||||
var known = post.Emojis.Select(e => (e.Shortcode, e.URL))
|
||||
.Concat((await DB.Default.Find<Reaction>().Match(r => r.PostId == post.ID && r.EmojiURL != null).ExecuteAsync(token)).Select(r => (r.Emoji, r.EmojiURL)))
|
||||
.Select(e => new Models.Post.CustomEmoji { Shortcode = e.Item1, URL = e.Item2 })
|
||||
.ToList();
|
||||
if (Normalise(emoji, known) is not { } picked)
|
||||
var picked = LocalEmojis.ForReaction(emoji, me.Domain) is { } local ? new Picked(local.Shortcode, local.Url) : default;
|
||||
if (picked == default)
|
||||
{
|
||||
var known = post.Emojis.Select(e => (e.Shortcode, e.URL))
|
||||
.Concat((await DB.Default.Find<Reaction>().Match(r => r.PostId == post.ID && r.EmojiURL != null).ExecuteAsync(token)).Select(r => (r.Emoji, r.EmojiURL)))
|
||||
.Select(e => new Models.Post.CustomEmoji { Shortcode = e.Item1, URL = e.Item2 })
|
||||
.ToList();
|
||||
picked = Normalise(emoji, known);
|
||||
}
|
||||
if (picked == default)
|
||||
return false;
|
||||
var reaction = new Reaction
|
||||
{
|
||||
@@ -114,7 +122,7 @@ namespace PrivaPub.Domain.Social
|
||||
|
||||
public async Task<bool> Unreact(LocalActor me, PostEntity post, string emoji, CancellationToken token)
|
||||
{
|
||||
var name = Normalise(emoji, post.Emojis.ToList(), requireKnownCustom: false)?.Name;
|
||||
var name = LocalEmojis.ForReaction(emoji, me.Domain)?.Shortcode ?? Normalise(emoji, post.Emojis.ToList(), requireKnownCustom: false)?.Name;
|
||||
var reaction = name == default
|
||||
? default
|
||||
: await DB.Default.Find<Reaction>().Match(r => r.PostId == post.ID && r.ActorURI == me.Uri && r.Emoji == name).ExecuteFirstAsync(token);
|
||||
@@ -145,13 +153,17 @@ namespace PrivaPub.Domain.Social
|
||||
["to"] = new JsonArray(post.ActorURI),
|
||||
["content"] = reaction.EmojiURL == default ? reaction.Emoji : $":{reaction.Emoji}:"
|
||||
};
|
||||
// one of this server's emoji goes as posts carry it (its id, `updated`, the picture's type), as Misskey and Akkoma
|
||||
// register it; another server's as the post showed it
|
||||
if (reaction.EmojiURL != default)
|
||||
react["tag"] = new JsonArray(new JsonObject
|
||||
{
|
||||
["type"] = "Emoji",
|
||||
["name"] = $":{reaction.Emoji}:",
|
||||
["icon"] = new JsonObject { ["type"] = "Image", ["url"] = reaction.EmojiURL }
|
||||
});
|
||||
react["tag"] = new JsonArray(LocalEmojis.Find(reaction.Emoji) is { } local && local.Url == reaction.EmojiURL
|
||||
? LocalEmojis.Tag(local, me.BaseAddress)
|
||||
: new JsonObject
|
||||
{
|
||||
["type"] = "Emoji",
|
||||
["name"] = $":{reaction.Emoji}:",
|
||||
["icon"] = new JsonObject { ["type"] = "Image", ["url"] = reaction.EmojiURL }
|
||||
});
|
||||
return react;
|
||||
}
|
||||
|
||||
|
||||
@@ -230,6 +230,7 @@ namespace PrivaPub.Domain.Statuses
|
||||
QuoteState = quoted == default ? QuoteState.None : quotePermission == QuotePermission.Granted ? QuoteState.Accepted : QuoteState.Pending,
|
||||
Approval = replyPermission == QuotePermission.AskFirst ? ApprovalState.Pending : ApprovalState.None
|
||||
};
|
||||
post.Emojis = Emojis(post);
|
||||
post.ID = (string)post.GenerateNewID();
|
||||
post.ObjectURI = author.PostUri(post.ID);
|
||||
post.Url = author.PostHtmlUrl(post.ID);
|
||||
@@ -300,6 +301,10 @@ namespace PrivaPub.Domain.Statuses
|
||||
return new StatusOutcome(post);
|
||||
}
|
||||
|
||||
// the server's custom emoji its words, title, warning and poll options name (R7): only ours that are offered now
|
||||
static List<CustomEmoji> Emojis(PostEntity post) =>
|
||||
LocalEmojis.In(new[] { post.Text, post.Title, post.SpoilerText }.Concat(post.Poll?.Options.Select(o => o.Title) ?? Enumerable.Empty<string>()));
|
||||
|
||||
public async Task<StatusOutcome> Edit(LocalActor author, string postId, StatusDraft draft, CancellationToken token)
|
||||
{
|
||||
var post = await Own(author, postId, token);
|
||||
@@ -319,7 +324,8 @@ namespace PrivaPub.Domain.Statuses
|
||||
SpoilerText = post.SpoilerText,
|
||||
ContentHtml = post.ContentHtml,
|
||||
HasContentWarning = post.HasContentWarning,
|
||||
EditedAt = post.EditedAt ?? post.CreationDate
|
||||
EditedAt = post.EditedAt ?? post.CreationDate,
|
||||
Emojis = post.Emojis.ToList()
|
||||
});
|
||||
if (post.Revisions.Count > MaxRevisions)
|
||||
post.Revisions.RemoveRange(0, post.Revisions.Count - MaxRevisions);
|
||||
@@ -363,6 +369,7 @@ namespace PrivaPub.Domain.Statuses
|
||||
? post.Mentions.Concat(rendered.Mentions.Select(ToMention)).DistinctBy(m => m.ActorURI).ToList()
|
||||
: rendered.Mentions.Select(ToMention).ToList();
|
||||
post.Tags = rendered.Tags.ToList();
|
||||
post.Emojis = Emojis(post);
|
||||
post.EditedAt = DateTime.UtcNow;
|
||||
post.UpdateDate = post.EditedAt;
|
||||
await DB.Default.SaveAsync(post, token);
|
||||
|
||||
@@ -43,6 +43,8 @@ namespace PrivaPub.Federation.Actors
|
||||
public DateTime Published { get; init; }
|
||||
public string BaseAddress { get; init; }
|
||||
public IReadOnlyDictionary<string, string> Fields { get; init; } = new Dictionary<string, string>();
|
||||
// the custom emoji of this server its profile named (R7); rendered as they are now (LocalEmojis.Of)
|
||||
public IReadOnlyList<Models.Post.CustomEmoji> Emojis { get; init; } = Array.Empty<Models.Post.CustomEmoji>();
|
||||
|
||||
public string Uri => $"{BaseAddress}/peasants/{UserName}";
|
||||
public string KeyId => $"{Uri}#main-key";
|
||||
@@ -335,6 +337,7 @@ namespace PrivaPub.Federation.Actors
|
||||
SigningKey = avatar.SigningKey,
|
||||
Published = avatar.PublishedOn,
|
||||
Fields = avatar.Fields ?? new Dictionary<string, string>(),
|
||||
Emojis = avatar.Emojis ?? new List<Models.Post.CustomEmoji>(),
|
||||
ManuallyApprovesFollowers = avatar.Settings?.IsLocked == true,
|
||||
Discoverable = avatar.Settings?.IsDiscoverable != false,
|
||||
IsBot = avatar.Settings?.IsBot == true,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Domain.Content;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Models.Post;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Federation.Controllers
|
||||
{
|
||||
// Each of the server's custom emoji as an ActivityPub `Emoji` object at its id, /emojis/{shortcode} (completion program
|
||||
// R7; the path is LocalEmojis.Path): the tag that posts, actors and reactions carry, with its @context. GoToSocial takes
|
||||
// an emoji only with an id, and may read it there. A disabled emoji is not served (404, it may come back); a deleted one
|
||||
// answers 410 with a Tombstone. An emoji tells nothing about anyone, so it is served unsigned under SecureMode too, as the
|
||||
// server's own actors are; a browser is sent to the picture.
|
||||
[ApiController]
|
||||
public class EmojisController : ControllerBase
|
||||
{
|
||||
const string ActivityContentType = "application/activity+json; charset=utf-8";
|
||||
|
||||
readonly ILocalActorService _localActors;
|
||||
|
||||
public EmojisController(ILocalActorService localActors)
|
||||
{
|
||||
_localActors = localActors;
|
||||
}
|
||||
|
||||
[HttpGet, Route("/" + LocalEmojis.Path + "/{shortcode}")]
|
||||
public async Task<IActionResult> Get(string shortcode, CancellationToken token)
|
||||
{
|
||||
Response.Headers.Vary = "Accept";
|
||||
if (!LocalEmojis.IsShortcode(shortcode))
|
||||
return NotFound();
|
||||
var key = shortcode.ToLowerInvariant();
|
||||
var emoji = await DB.Default.Find<LocalEmoji>().Match(e => e.Key == key).ExecuteFirstAsync(token);
|
||||
if (emoji == default || emoji.Shortcode != shortcode)
|
||||
return NotFound();
|
||||
if (emoji.DeletedAt is { } deleted)
|
||||
return new ContentResult
|
||||
{
|
||||
Content = new JsonObject
|
||||
{
|
||||
["@context"] = ActivityPubRenderer.ActivityStreams,
|
||||
["id"] = LocalEmojis.Uri(_localActors.BaseAddress, emoji.Shortcode),
|
||||
["type"] = "Tombstone",
|
||||
["formerType"] = "Emoji",
|
||||
["deleted"] = ActivityPubRenderer.Timestamp(deleted)
|
||||
}.ToJsonString(),
|
||||
ContentType = ActivityContentType,
|
||||
StatusCode = StatusCodes.Status410Gone
|
||||
};
|
||||
if (emoji.Disabled || string.IsNullOrEmpty(emoji.Url))
|
||||
return NotFound();
|
||||
if (WantsHtml())
|
||||
return Redirect(emoji.Url);
|
||||
var live = new LocalEmojis.Live(emoji.ID, emoji.Shortcode, emoji.Url, emoji.StaticUrl ?? emoji.Url, emoji.ContentType ?? "image/png",
|
||||
emoji.ImageUpdatedAt, emoji.Category, emoji.VisibleInPicker);
|
||||
return new ContentResult
|
||||
{
|
||||
Content = LocalEmojis.Document(live, _localActors.BaseAddress).ToJsonString(),
|
||||
ContentType = ActivityContentType,
|
||||
StatusCode = StatusCodes.Status200OK
|
||||
};
|
||||
}
|
||||
|
||||
bool WantsHtml()
|
||||
{
|
||||
var accept = Request.Headers.Accept.ToString();
|
||||
return accept.Contains("text/html", StringComparison.OrdinalIgnoreCase)
|
||||
&& !accept.Contains("activity+json", StringComparison.OrdinalIgnoreCase)
|
||||
&& !accept.Contains("ld+json", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,8 @@ namespace PrivaPub.Federation.Inbox
|
||||
SpoilerText = post.SpoilerText,
|
||||
ContentHtml = post.ContentHtml,
|
||||
HasContentWarning = post.HasContentWarning,
|
||||
EditedAt = post.EditedAt ?? post.CreationDate
|
||||
EditedAt = post.EditedAt ?? post.CreationDate,
|
||||
Emojis = post.Emojis.ToList()
|
||||
});
|
||||
if (post.Revisions.Count > MaxRevisions)
|
||||
post.Revisions.RemoveRange(0, post.Revisions.Count - MaxRevisions);
|
||||
|
||||
@@ -165,7 +165,8 @@ namespace PrivaPub.Federation.Objects
|
||||
SpoilerText = warns ? Plain(summary, 500) : default,
|
||||
ContentHtml = ContentSanitizer.Html(Content(version).Html),
|
||||
HasContentWarning = sensitive,
|
||||
EditedAt = Time(Value(version, "updated")) ?? Time(Value(version, "published")) ?? default
|
||||
EditedAt = Time(Value(version, "updated")) ?? Time(Value(version, "published")) ?? default,
|
||||
Emojis = ObjectShapes.Emojis(version["tag"])
|
||||
};
|
||||
})
|
||||
.Reverse()
|
||||
|
||||
@@ -4,6 +4,7 @@ using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Domain.Content;
|
||||
using PrivaPub.Domain.Media;
|
||||
|
||||
using System.Globalization;
|
||||
@@ -188,6 +189,10 @@ namespace PrivaPub.Federation.Rendering
|
||||
["controller"] = actor.Uri,
|
||||
["publicKeyMultibase"] = Signing.IntegrityProofs.Multikey(Signing.IntegrityProofs.PublicKey(actor.SigningKey))
|
||||
});
|
||||
// the server's custom emoji its name, bio and fields use (R7), only when there are some
|
||||
var emojis = LocalEmojis.Of(actor.Emojis);
|
||||
if (emojis.Count > 0)
|
||||
document["tag"] = new JsonArray(emojis.Select(e => (JsonNode)LocalEmojis.Tag(e, actor.BaseAddress)).ToArray());
|
||||
if (!string.IsNullOrEmpty(actor.PictureURL))
|
||||
document["icon"] = new JsonObject { ["type"] = "Image", ["url"] = actor.PictureURL };
|
||||
if (!string.IsNullOrEmpty(actor.ThumbnailURL))
|
||||
@@ -355,6 +360,10 @@ namespace PrivaPub.Federation.Rendering
|
||||
["href"] = TagUrl(author.BaseAddress, t),
|
||||
["name"] = "#" + t
|
||||
}))
|
||||
// this server's custom emoji its words, warning and poll options use, as they are now (R7); another server's
|
||||
// post (a copy we hold) carries its own
|
||||
.Concat(post.IsFederatedCopy ? Enumerable.Empty<JsonNode>()
|
||||
: LocalEmojis.Of(post.Emojis).Select(e => (JsonNode)LocalEmojis.Tag(e, author.BaseAddress)))
|
||||
.ToArray())
|
||||
};
|
||||
AddQuote(note, post, ref content);
|
||||
|
||||
@@ -15,9 +15,11 @@ namespace PrivaPub.Infrastructure.Backup
|
||||
// - a root keeps P's password, e-mail, ban and policies;
|
||||
// - roots, personas and groups made since become tombstones: deleted, their names kept; local posts made since answer
|
||||
// 410; media made since go to the trash, as do media trashed since (and they leave the posts restored with them),
|
||||
// but the server's own pictures the live settings hold;
|
||||
// but the server's own pictures the live settings and custom emoji hold; a picture of the server's that neither holds
|
||||
// any more goes to the trash;
|
||||
// - the staff's log is the union (a restore never loses an entry, its own request included), and the server's
|
||||
// settings are P's: the live settings outlive a restore;
|
||||
// settings are P's: the live settings outlive a restore; so do its custom emoji (R7), as P has them: one deleted since
|
||||
// stays deleted (its id answers 410), one added or changed since stays as it is;
|
||||
// - every session ends, and every server's circuit is closed again.
|
||||
// Run on the imported database, it reads P's files only, so it can run again after a failed attempt.
|
||||
public static class ProtectiveMerge
|
||||
@@ -44,8 +46,8 @@ namespace PrivaPub.Infrastructure.Backup
|
||||
IEnumerable<BsonDocument> P(string collection) => ServerRestore.Read(Path.Combine(previous, collection + ".jsonl.gz"));
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// who follows whom: P's; and the server's settings as they were live
|
||||
foreach (var collection in new[] { "Follower", "Following", "ServerSettings" })
|
||||
// who follows whom: P's; and the server's settings and custom emoji as they were live
|
||||
foreach (var collection in new[] { "Follower", "Following", "ServerSettings", "LocalEmoji" })
|
||||
{
|
||||
await database.DropCollectionAsync(collection, token);
|
||||
await ServerRestore.Insert(database.GetCollection<BsonDocument>(collection), P(collection), token);
|
||||
@@ -151,6 +153,7 @@ namespace PrivaPub.Infrastructure.Backup
|
||||
|
||||
await Posts(database, P("Post"), report, token);
|
||||
await Media(database, P("MediaAttachment"), report, token);
|
||||
await ServerPictures(database, report, token);
|
||||
// what the staff took down stays down, and every post's flags follow the moderation now kept (R6)
|
||||
foreach (var post in P("Post").Where(p => IsSet(p, "ModeratedAt")))
|
||||
await database.GetCollection<BsonDocument>("Post").UpdateOneAsync(
|
||||
@@ -228,8 +231,8 @@ namespace PrivaPub.Infrastructure.Backup
|
||||
{
|
||||
if (!restored.TryGetValue(row["_id"], out var mine))
|
||||
{
|
||||
// made since, and to the trash, but a picture of the server's own (its thumbnail): the live settings,
|
||||
// which a restore keeps, still show it
|
||||
// made since, and to the trash, but a picture of the server's own (its thumbnail, a custom emoji's):
|
||||
// the live settings and emoji, which a restore keeps, still show it
|
||||
if (!IsSet(row, "TrashedAt") && !IsSet(row, "ServerUse"))
|
||||
{
|
||||
row["TrashedAt"] = now;
|
||||
@@ -258,6 +261,31 @@ namespace PrivaPub.Infrastructure.Backup
|
||||
Builders<BsonDocument>.Update.Set("TrashedAt", now).Set("TrashReason", "restore: its post was deleted"), cancellationToken: token)).ModifiedCount;
|
||||
}
|
||||
|
||||
// the server's own pictures (ServerUse) that neither the settings nor a custom emoji, both P's now, hold: to the trash.
|
||||
// One the backup still had and that was replaced and purged since would otherwise be held by nothing for ever.
|
||||
static async Task ServerPictures(IMongoDatabase database, RestoreReport report, CancellationToken token)
|
||||
{
|
||||
var held = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var settings in await (await database.GetCollection<BsonDocument>("ServerSettings").FindAsync(FilterDefinition<BsonDocument>.Empty,
|
||||
cancellationToken: token)).ToListAsync(token))
|
||||
if (settings.GetValue("ThumbnailMediaId", BsonNull.Value) is BsonString thumbnail)
|
||||
held.Add(thumbnail.AsString);
|
||||
foreach (var emoji in await (await database.GetCollection<BsonDocument>("LocalEmoji").FindAsync(new BsonDocument("DeletedAt", BsonNull.Value),
|
||||
cancellationToken: token)).ToListAsync(token))
|
||||
if (emoji.GetValue("MediaId", BsonNull.Value) is BsonString picture)
|
||||
held.Add(picture.AsString);
|
||||
var media = database.GetCollection<BsonDocument>("MediaAttachment");
|
||||
var rows = await (await media.FindAsync(new BsonDocument { { "ServerUse", new BsonDocument("$ne", BsonNull.Value) }, { "TrashedAt", BsonNull.Value } },
|
||||
new FindOptions<BsonDocument> { Projection = new BsonDocument("_id", 1) }, token)).ToListAsync(token);
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var row in rows.Where(r => !held.Contains(r["_id"].ToString())))
|
||||
{
|
||||
await media.UpdateOneAsync(Id(row["_id"]), Builders<BsonDocument>.Update.Set("TrashedAt", now).Set("TrashReason", "restore: no longer the server's"),
|
||||
cancellationToken: token);
|
||||
report.MediaTrashed++;
|
||||
}
|
||||
}
|
||||
|
||||
// P's rows added, each replacing the restored row with the same key: how many P had
|
||||
static async Task<int> Union(IMongoCollection<BsonDocument> collection, IEnumerable<BsonDocument> previous, string[] key, CancellationToken token)
|
||||
{
|
||||
|
||||
@@ -57,6 +57,8 @@ namespace PrivaPub.Infrastructure.Data
|
||||
await DB.Default.Index<AnnouncementReaction>().Key(r => r.AnnouncementId, KeyType.Ascending).Key(r => r.AvatarId, KeyType.Ascending)
|
||||
.Key(r => r.Name, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token);
|
||||
await Plain<AnnouncementReaction>(token, r => r.AvatarId);
|
||||
// the server's custom emoji, one per shortcode whatever its case (a new collection, R7)
|
||||
await Unique<LocalEmoji>(e => e.Key, Builders<LocalEmoji>.Filter.Gt(e => e.Key, ""), token);
|
||||
// the staff's log, read newest first
|
||||
await DB.Default.Index<Models.AdminAction>().Key(a => a.At, KeyType.Descending).CreateAsync(token);
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ namespace PrivaPub.Infrastructure.Statistics
|
||||
return "webfinger";
|
||||
if (path.StartsWithSegments("/.well-known/nodeinfo") || path.StartsWithSegments("/nodeinfo"))
|
||||
return "nodeinfo";
|
||||
if (path.StartsWithSegments("/" + Domain.Content.LocalEmojis.Path))
|
||||
return "emoji";//a custom emoji's document (R7)
|
||||
if (!path.StartsWithSegments("/peasants") && !path.StartsWithSegments("/users"))
|
||||
return default;
|
||||
var segments = (template ?? path.Value ?? string.Empty).Trim('/').Split('/');
|
||||
|
||||
@@ -60,6 +60,10 @@ namespace PrivaPub.Models
|
||||
public const string AccountUnsuspend = "account.unsuspend";
|
||||
public const string GroupDelete = "group.delete";
|
||||
public const string PostRemove = "post.remove";
|
||||
// the server's custom emoji (completion program R7)
|
||||
public const string EmojiInsert = "emoji.insert";
|
||||
public const string EmojiUpdate = "emoji.update";
|
||||
public const string EmojiDelete = "emoji.delete";
|
||||
}
|
||||
|
||||
public static class AdminTargets
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
namespace PrivaPub.Models.Post
|
||||
{
|
||||
// One of this server's own custom emoji (completion program R7), added by an administrator
|
||||
// (/clientapi/admin/emoji, EmojiAdminController). Its shortcode is its name everywhere: `:shortcode:` in a post, and
|
||||
// its federated id (/emojis/{shortcode}, Domain/Content/LocalEmojis.Path), which is why it is never renamed. Its picture
|
||||
// is a MediaAttachment nobody owns, held by ServerUse "emoji:<shortcode>" while the emoji exists. Deleted, the row stays
|
||||
// (DeletedAt) so its id answers 410; adding the same shortcode again brings the row back with a new picture.
|
||||
public class LocalEmoji : Entity
|
||||
{
|
||||
public string Shortcode { get; set; }//as written, [a-zA-Z0-9_]{2,64} (Mastodon's rule); matched exactly in texts
|
||||
public string Key { get; set; }//the shortcode in lower case: unique, so `Blob` and `blob` are never two emoji
|
||||
public string Category { get; set; }
|
||||
public bool VisibleInPicker { get; set; } = true;
|
||||
public bool Disabled { get; set; } = false;//kept, but offered nowhere and taken into no new post
|
||||
public string MediaId { get; set; }//its picture's MediaAttachment (ServerUse "emoji:<shortcode>")
|
||||
public string Url { get; set; }//the picture: a PNG, or an animated GIF
|
||||
public string StaticUrl { get; set; }//a still PNG of it (the first frame of a GIF; the picture itself otherwise)
|
||||
public string ContentType { get; set; }//image/png or image/gif
|
||||
public int? Width { get; set; }
|
||||
public int? Height { get; set; }
|
||||
public long Size { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;//any change
|
||||
public DateTime ImageUpdatedAt { get; set; } = DateTime.UtcNow;//its picture's: the Emoji's `updated`, which tells peers to fetch it again
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -160,5 +160,6 @@ namespace PrivaPub.Models.Post
|
||||
public string ContentHtml { get; set; }
|
||||
public bool HasContentWarning { get; set; }
|
||||
public DateTime EditedAt { get; set; }
|
||||
public List<CustomEmoji> Emojis { get; set; } = new();//the post's custom emoji in that version (none kept before R7)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ namespace PrivaPub.Models.User
|
||||
public AvatarAccountState AccountState { get; set; } = AvatarAccountState.Normal;
|
||||
public AvatarSettings Settings { get; set; } = new();
|
||||
public Dictionary<string, string> Fields { get; set; } = new();
|
||||
// this server's custom emoji its name, bio and fields named when they were saved (R7, Domain/Content/LocalEmojis)
|
||||
public List<PrivaPub.Models.Post.CustomEmoji> Emojis { get; set; } = new();
|
||||
|
||||
public string Domain { get; set; }
|
||||
public string PersonalNote { get; set; }
|
||||
|
||||
@@ -232,6 +232,7 @@ try
|
||||
await dbClient.Init();
|
||||
await app.Services.GetRequiredService<PrivaPub.Federation.Moderation.IDomainBlocks>().Reload(CancellationToken.None);
|
||||
await PrivaPub.Domain.Moderation.Restrictions.Reload(CancellationToken.None);//who the staff's moderation holds back (R6)
|
||||
await PrivaPub.Domain.Content.LocalEmojis.Reload(CancellationToken.None);//the server's custom emoji (R7)
|
||||
await PrivaPub.Api.Mastodon.Auth.PersonaExchange.EnsureFirstPartyClient(app.Services, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -70,6 +70,7 @@ namespace PrivaPub.Services.ClientToServer.Private
|
||||
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]);
|
||||
@@ -119,6 +120,7 @@ namespace PrivaPub.Services.ClientToServer.Private
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user