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
@@ -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)
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user