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 { /// /// 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. /// public partial class EmojiService(IHttpService httpService, ILocalStorageService localStorage, ILoggingService logger, CoalescingStringLocalizer localizer) { /// The largest picture PrivaPub takes for an emoji (its MediaService.EmojiMaxUploadBytes). public const long MaxPictureBytes = 256 * 1024; /// The kinds of picture PrivaPub takes for an emoji; it keeps a PNG, or an animated GIF. public static readonly string[] PictureTypes = ["image/png", "image/gif", "image/webp", "image/jpeg"]; static readonly Dictionary 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(@"(?> reading; /// What a picked file is: the browser's word, else its extension's; null when neither says. public static string TypeOf(IBrowserFile file) => file.ContentType is { Length: > 0 } type ? type.ToLowerInvariant() : Extensions.GetValueOrDefault(Path.GetExtension(file.Name) ?? string.Empty); /// The emoji the server's picker offers, by shortcode: kept a day, and read once however many ask at a time. public async Task> Custom(CancellationToken cancellationToken = default) { if (IsFresh(cache)) return cache.Emojis; reading ??= Read(cancellationToken); var emojis = await reading; reading = null; return emojis; } /// One of them by its exact shortcode, from what was read already; null when it isn't known. public CustomEmoji Known(string shortcode) => cache?.Emojis.Find(e => e.Shortcode == shortcode); /// The ones the texts name as `:shortcode:`, for this server's own words that come without their emoji /// (a located post). public async Task> NamedIn(IEnumerable 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))]; } /// The ones whose shortcode holds what is typed, those starting with it first, `count` at most. public async Task> 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)]; } /// Reads them again now, after this client changed one. 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> 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>(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 Stored(CancellationToken cancellationToken) { try { return await localStorage.GetItemAsync(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); } } /// Every emoji of the server, disabled ones too (deleted ones never), by shortcode. public async Task> List(CancellationToken cancellationToken = default) { using var response = await httpService.Get(APIs.ClientApi.GET_AdminEmojis, cancellationToken: cancellationToken); if (response.IsSuccessStatusCode) return await response.DefaultReadFromJsonAsync>(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 []; } /// 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. public Task Insert(LocalEmojiForm form, IBrowserFile picture, CancellationToken cancellationToken = default) => Save(APIs.ClientApi.POST_AdminEmojiInsert, form, picture, cancellationToken); /// 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. public Task Update(LocalEmojiForm form, IBrowserFile picture, CancellationToken cancellationToken = default) => Save(APIs.ClientApi.POST_AdminEmojiUpdate, form, picture, cancellationToken); /// Deletes an emoji: offered nowhere, and the posts and profiles that use it show its shortcode as text. public async Task 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 Save(string route, LocalEmojiForm form, IBrowserFile picture, CancellationToken cancellationToken) { using var response = await httpService.Post(route, (Func)(() => Multipart(form, picture, cancellationToken)), cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); var saved = await response.DefaultReadFromJsonAsync(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; } } }