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:
thepraandClaude Opus 5.5 committed 2026-10-17 05:57:25 +02:00
1 parent 9f68ca3ed0
commit d08fbabaa5
38 files changed
+1117 -54

No files matched your search

@@ -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);
}
}
}
+2 -1
View File
@@ -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);
+2 -1
View File
@@ -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);