229 lines
11 KiB
C#
229 lines
11 KiB
C#
using System.Net;
|
|||
|
|
using System.Net.Http.Headers;
|
||
|
|
using System.Text.RegularExpressions;
|
||
|
|
|
||
|
|
using Blazored.LocalStorage;
|
||
|
|
|
||
|
|
using decePubClient.Extensions;
|
||
|
|
using decePubClient.Models;
|
||
|
|
using decePubClient.Models.Mastodon;
|
||
|
|
|
||
|
|
using Microsoft.AspNetCore.Components.Forms;
|
||
|
|
|
||
|
|
using PrivaPub.ClientModels;
|
||
|
|
using PrivaPub.ClientModels.Admin;
|
||
|
|
|
||
|
|
namespace decePubClient.Services
|
||
|
|
{
|
||
|
|
/// <summary>
|
||
|
|
/// The server's own custom emoji (PrivaPub's completion program R7). For everyone: the ones its picker offers
|
||
|
|
/// (/api/v1/custom_emojis, read with no credentials; neither disabled nor kept out of the picker), kept in memory and in
|
||
|
|
/// localStorage for a day (CustomEmojiCache), and read again at once after an administrator changes one in this client.
|
||
|
|
/// The picker, the composer's `:co` suggestions and located posts read them. For administrators
|
||
|
|
/// (/clientapi/admin/emoji, PrivaPub's EmojiAdminController): every emoji, disabled ones too, added and changed with its
|
||
|
|
/// picture streamed from the browser's file as multipart `image`, and deleted. Lists come back empty on failure;
|
||
|
|
/// nothing throws.
|
||
|
|
/// </summary>
|
||
|
|
public partial class EmojiService(IHttpService httpService, ILocalStorageService localStorage, ILoggingService logger, CoalescingStringLocalizer localizer)
|
||
|
|
{
|
||
|
|
/// <summary>The largest picture PrivaPub takes for an emoji (its MediaService.EmojiMaxUploadBytes).</summary>
|
||
|
|
public const long MaxPictureBytes = 256 * 1024;
|
||
|
|
|
||
|
|
/// <summary>The kinds of picture PrivaPub takes for an emoji; it keeps a PNG, or an animated GIF.</summary>
|
||
|
|
public static readonly string[] PictureTypes = ["image/png", "image/gif", "image/webp", "image/jpeg"];
|
||
|
|
|
||
|
|
static readonly Dictionary<string, string> Extensions = new(StringComparer.OrdinalIgnoreCase)
|
||
|
|
{
|
||
|
|
[".png"] = "image/png", [".gif"] = "image/gif", [".webp"] = "image/webp", [".jpg"] = "image/jpeg", [".jpeg"] = "image/jpeg"
|
||
|
|
};
|
||
|
|
|
||
|
|
static readonly TimeSpan Lifetime = TimeSpan.FromDays(1);
|
||
|
|
// after a failed read, what was held (or nothing) is used this long before the server is asked again
|
||
|
|
static readonly TimeSpan RetryAfter = TimeSpan.FromMinutes(5);
|
||
|
|
const string StorageKey = nameof(CustomEmojiCache);
|
||
|
|
|
||
|
|
// `:shortcode:` with no letter or digit right before or after it, as PrivaPub and Mastodon read texts
|
||
|
|
[GeneratedRegex(@"(?<![a-zA-Z0-9]):([a-zA-Z0-9_]{2,64}):(?![a-zA-Z0-9])")]
|
||
|
|
private static partial Regex Named();
|
||
|
|
|
||
|
|
CustomEmojiCache cache;
|
||
|
|
bool isStoredRead;
|
||
|
|
Task<List<CustomEmoji>> reading;
|
||
|
|
|
||
|
|
/// <summary>What a picked file is: the browser's word, else its extension's; null when neither says.</summary>
|
||
|
|
public static string TypeOf(IBrowserFile file) => file.ContentType is { Length: > 0 } type
|
||
|
|
? type.ToLowerInvariant()
|
||
|
|
: Extensions.GetValueOrDefault(Path.GetExtension(file.Name) ?? string.Empty);
|
||
|
|
|
||
|
|
/// <summary>The emoji the server's picker offers, by shortcode: kept a day, and read once however many ask at a time.</summary>
|
||
|
|
public async Task<List<CustomEmoji>> Custom(CancellationToken cancellationToken = default)
|
||
|
|
{
|
||
|
|
if (IsFresh(cache))
|
||
|
|
return cache.Emojis;
|
||
|
|
|
||
|
|
reading ??= Read(cancellationToken);
|
||
|
|
var emojis = await reading;
|
||
|
|
reading = null;
|
||
|
|
return emojis;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>One of them by its exact shortcode, from what was read already; null when it isn't known.</summary>
|
||
|
|
public CustomEmoji Known(string shortcode) => cache?.Emojis.Find(e => e.Shortcode == shortcode);
|
||
|
|
|
||
|
|
/// <summary>The ones the texts name as `:shortcode:`, for this server's own words that come without their emoji
|
||
|
|
/// (a located post).</summary>
|
||
|
|
public async Task<List<CustomEmoji>> NamedIn(IEnumerable<string> texts, CancellationToken cancellationToken = default)
|
||
|
|
{
|
||
|
|
var codes = texts.Where(t => !string.IsNullOrEmpty(t)).SelectMany(t => Named().Matches(t).Select(m => m.Groups[1].Value)).ToHashSet(StringComparer.Ordinal);
|
||
|
|
if (codes.Count == 0)
|
||
|
|
return [];
|
||
|
|
|
||
|
|
return [.. (await Custom(cancellationToken)).Where(e => codes.Contains(e.Shortcode))];
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>The ones whose shortcode holds what is typed, those starting with it first, `count` at most.</summary>
|
||
|
|
public async Task<List<CustomEmoji>> Matching(string typed, int count, CancellationToken cancellationToken = default)
|
||
|
|
{
|
||
|
|
if (string.IsNullOrEmpty(typed))
|
||
|
|
return [];
|
||
|
|
|
||
|
|
return [.. (await Custom(cancellationToken))
|
||
|
|
.Where(e => e.Shortcode.Contains(typed, StringComparison.OrdinalIgnoreCase))
|
||
|
|
.OrderBy(e => e.Shortcode.StartsWith(typed, StringComparison.OrdinalIgnoreCase) ? 0 : 1)
|
||
|
|
.ThenBy(e => e.Shortcode.Length)
|
||
|
|
.Take(count)];
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>Reads them again now, after this client changed one.</summary>
|
||
|
|
public async Task Refresh(CancellationToken cancellationToken = default)
|
||
|
|
{
|
||
|
|
isStoredRead = true;
|
||
|
|
cache = null;
|
||
|
|
await Custom(cancellationToken);
|
||
|
|
}
|
||
|
|
|
||
|
|
static bool IsFresh(CustomEmojiCache held) => held is not null && DateTime.UtcNow - held.FetchedAt < Lifetime;
|
||
|
|
|
||
|
|
async Task<List<CustomEmoji>> Read(CancellationToken cancellationToken)
|
||
|
|
{
|
||
|
|
if (!isStoredRead)
|
||
|
|
{
|
||
|
|
isStoredRead = true;
|
||
|
|
cache = await Stored(cancellationToken);
|
||
|
|
if (IsFresh(cache))
|
||
|
|
return cache.Emojis;
|
||
|
|
}
|
||
|
|
|
||
|
|
using var response = await httpService.GetTotallyAnon(APIs.Mastodon.GET_CustomEmojis, cancellationToken: cancellationToken);
|
||
|
|
if (!response.IsSuccessStatusCode)
|
||
|
|
{
|
||
|
|
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||
|
|
if (result.StatusCode is not (int)HttpStatusCode.Gone)
|
||
|
|
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(EmojiService)}.{nameof(Custom)}", cancellationToken);
|
||
|
|
// what was held (a day old or more) stays in use a while, so a server out of reach isn't asked at every keystroke
|
||
|
|
cache = new CustomEmojiCache { FetchedAt = DateTime.UtcNow - Lifetime + RetryAfter, Emojis = cache?.Emojis ?? [] };
|
||
|
|
return cache.Emojis;
|
||
|
|
}
|
||
|
|
|
||
|
|
var emojis = await response.MastodonReadFromJsonAsync<List<CustomEmoji>>(cancellationToken) ?? [];
|
||
|
|
cache = new CustomEmojiCache
|
||
|
|
{
|
||
|
|
FetchedAt = DateTime.UtcNow,
|
||
|
|
Emojis = [.. emojis.Where(e => e.Shortcode is { Length: > 0 } && e.Url is { Length: > 0 }).OrderBy(e => e.Shortcode, StringComparer.Ordinal)]
|
||
|
|
};
|
||
|
|
await Store(cache, cancellationToken);
|
||
|
|
return cache.Emojis;
|
||
|
|
}
|
||
|
|
|
||
|
|
async Task<CustomEmojiCache> Stored(CancellationToken cancellationToken)
|
||
|
|
{
|
||
|
|
try
|
||
|
|
{
|
||
|
|
return await localStorage.GetItemAsync<CustomEmojiCache>(StorageKey, cancellationToken);
|
||
|
|
}
|
||
|
|
catch (Exception ex)
|
||
|
|
{
|
||
|
|
await logger.ProcessWarning(ex.Message, $"{nameof(EmojiService)}.{nameof(Stored)}", cancellationToken);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async Task Store(CustomEmojiCache read, CancellationToken cancellationToken)
|
||
|
|
{
|
||
|
|
try
|
||
|
|
{
|
||
|
|
await localStorage.SetItemAsync(StorageKey, read, cancellationToken);
|
||
|
|
}
|
||
|
|
catch (Exception ex)
|
||
|
|
{
|
||
|
|
await logger.ProcessWarning(ex.Message, $"{nameof(EmojiService)}.{nameof(Store)}", cancellationToken);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>Every emoji of the server, disabled ones too (deleted ones never), by shortcode.</summary>
|
||
|
|
public async Task<List<ViewLocalEmoji>> List(CancellationToken cancellationToken = default)
|
||
|
|
{
|
||
|
|
using var response = await httpService.Get(APIs.ClientApi.GET_AdminEmojis, cancellationToken: cancellationToken);
|
||
|
|
if (response.IsSuccessStatusCode)
|
||
|
|
return await response.DefaultReadFromJsonAsync<List<ViewLocalEmoji>>(cancellationToken) ?? [];
|
||
|
|
|
||
|
|
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||
|
|
if (result.StatusCode is not ((int)HttpStatusCode.Gone or (int)HttpStatusCode.Forbidden))
|
||
|
|
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(EmojiService)}.{nameof(List)}", cancellationToken);
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>Adds an emoji with its picture; Data is the ViewLocalEmoji. PrivaPub answers 409 for a shortcode taken
|
||
|
|
/// (whatever its case), 422 for a picture it refuses (over 256 KB, of another kind, unreadable, or one it can't make
|
||
|
|
/// small enough) and 400 for a form that isn't valid.</summary>
|
||
|
|
public Task<WebResult> Insert(LocalEmojiForm form, IBrowserFile picture, CancellationToken cancellationToken = default) =>
|
||
|
|
Save(APIs.ClientApi.POST_AdminEmojiInsert, form, picture, cancellationToken);
|
||
|
|
|
||
|
|
/// <summary>Makes the emoji with the form's shortcode what the form says (its category, the picker, disabled or not),
|
||
|
|
/// with a new picture when one is given; an emoji is never renamed. Data is the ViewLocalEmoji.</summary>
|
||
|
|
public Task<WebResult> Update(LocalEmojiForm form, IBrowserFile picture, CancellationToken cancellationToken = default) =>
|
||
|
|
Save(APIs.ClientApi.POST_AdminEmojiUpdate, form, picture, cancellationToken);
|
||
|
|
|
||
|
|
/// <summary>Deletes an emoji: offered nowhere, and the posts and profiles that use it show its shortcode as text.</summary>
|
||
|
|
public async Task<WebResult> Delete(string shortcode, CancellationToken cancellationToken = default)
|
||
|
|
{
|
||
|
|
using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminEmojiDelete(shortcode), cancellationToken: cancellationToken);
|
||
|
|
if (!response.IsSuccessStatusCode)
|
||
|
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||
|
|
|
||
|
|
await Refresh(cancellationToken);
|
||
|
|
return new WebResult();
|
||
|
|
}
|
||
|
|
|
||
|
|
async Task<WebResult> Save(string route, LocalEmojiForm form, IBrowserFile picture, CancellationToken cancellationToken)
|
||
|
|
{
|
||
|
|
using var response = await httpService.Post(route, (Func<HttpContent>)(() => Multipart(form, picture, cancellationToken)),
|
||
|
|
cancellationToken: cancellationToken);
|
||
|
|
if (!response.IsSuccessStatusCode)
|
||
|
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||
|
|
|
||
|
|
var saved = await response.DefaultReadFromJsonAsync<ViewLocalEmoji>(cancellationToken);
|
||
|
|
await Refresh(cancellationToken);
|
||
|
|
return new WebResult { Data = saved };
|
||
|
|
}
|
||
|
|
|
||
|
|
// LocalEmojiForm as form fields, and the picture streamed from the file (never read into memory) as `image`
|
||
|
|
static MultipartFormDataContent Multipart(LocalEmojiForm form, IBrowserFile picture, CancellationToken cancellationToken)
|
||
|
|
{
|
||
|
|
var content = new MultipartFormDataContent
|
||
|
|
{
|
||
|
|
{ new StringContent(form.Shortcode ?? string.Empty), "shortcode" },
|
||
|
|
{ new StringContent(form.Category?.Trim() ?? string.Empty), "category" },
|
||
|
|
{ new StringContent(form.VisibleInPicker ? "true" : "false"), "visibleInPicker" },
|
||
|
|
{ new StringContent(form.Disabled ? "true" : "false"), "disabled" }
|
||
|
|
};
|
||
|
|
if (picture is null)
|
||
|
|
return content;
|
||
|
|
|
||
|
|
var image = new StreamContent(picture.OpenReadStream(MaxPictureBytes, cancellationToken));
|
||
|
|
image.Headers.ContentType = new MediaTypeHeaderValue(TypeOf(picture) ?? "application/octet-stream");
|
||
|
|
content.Add(image, "image", picture.Name);
|
||
|
|
return content;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|