From 1b3fa07c67370e1f7adab9506656e6898979667f Mon Sep 17 00:00:00 2001 From: Iris System Date: Sat, 25 Feb 2023 08:26:08 +1300 Subject: [PATCH 01/16] feat(bot): update help message footer with bot icon credit --- PluralKit.Bot/Commands/Help.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PluralKit.Bot/Commands/Help.cs b/PluralKit.Bot/Commands/Help.cs index ce73c522..50067fe0 100644 --- a/PluralKit.Bot/Commands/Help.cs +++ b/PluralKit.Bot/Commands/Help.cs @@ -11,7 +11,7 @@ public class Help { Title = "PluralKit", Description = "PluralKit is a bot designed for plural communities on Discord, and is open for anyone to use. It allows you to register systems, maintain system information, set up message proxying, log switches, and more.", - Footer = new("By @Ske#6201 | Myriad by @Layl#8888 | GitHub: https://github.com/PluralKit/PluralKit/ | Website: https://pluralkit.me/"), + Footer = new("By @Ske#6201 | Myriad design by @Layl#8888, art by https://twitter.com/sillyvizion | GitHub: https://github.com/PluralKit/PluralKit/ | Website: https://pluralkit.me/"), Color = DiscordUtils.Blue, }; From b2c61e3e8e7038d9462234a331e4680f172d2cfb Mon Sep 17 00:00:00 2001 From: Iris System Date: Mon, 27 Feb 2023 09:18:00 +1300 Subject: [PATCH 02/16] fix(bot): correctly use thread permissions in reproxy check --- PluralKit.Bot/Proxy/ProxyService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PluralKit.Bot/Proxy/ProxyService.cs b/PluralKit.Bot/Proxy/ProxyService.cs index 5a3290da..dc665d67 100644 --- a/PluralKit.Bot/Proxy/ProxyService.cs +++ b/PluralKit.Bot/Proxy/ProxyService.cs @@ -228,7 +228,7 @@ public class ProxyService var guildMember = await _rest.GetGuildMember(msg.Guild!.Value, trigger.Author.Id); // Grab user permissions - var senderPermissions = PermissionExtensions.PermissionsFor(guild, rootChannel, trigger.Author.Id, guildMember); + var senderPermissions = PermissionExtensions.PermissionsFor(guild, messageChannel, trigger.Author.Id, guildMember); var allowEveryone = senderPermissions.HasFlag(PermissionSet.MentionEveryone); // Make sure user has permissions to send messages From 5e60dec6ec1b1677bd4906901896ba1de1e2520c Mon Sep 17 00:00:00 2001 From: Iris System Date: Mon, 27 Feb 2023 09:34:48 +1300 Subject: [PATCH 03/16] fix(api, docs): send HTTP 400 on empty User-Agent, update docs --- docs/content/api/errors.md | 3 ++- docs/content/api/reference.md | 7 +++++++ services/web-proxy/main.go | 4 +++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/content/api/errors.md b/docs/content/api/errors.md index b8419c0e..7fae4f6c 100644 --- a/docs/content/api/errors.md +++ b/docs/content/api/errors.md @@ -31,8 +31,9 @@ When something goes wrong, the API will send back a 4xx HTTP status code, along |code|HTTP response code|meaning| |---|---|---| |0|500|Internal server error, try again later| -|0|400|Bad Request (usually invalid JSON)| +|0|400|Invalid JSON, or invalid request format (check `error` key in the response body)| |0|401|Missing or invalid Authorization header| +|0|403|Your access to the API is blocked - please contact us in the support server| |20001|404|System not found.| |20002|404|Member not found.| |20003|404|Member '{memberRef}' not found.| diff --git a/docs/content/api/reference.md b/docs/content/api/reference.md index d233a9ac..f5ab10fa 100644 --- a/docs/content/api/reference.md +++ b/docs/content/api/reference.md @@ -21,7 +21,14 @@ For models that have them, the keys `id`, `uuid` and `created` are **not** user- Endpoints taking JSON bodies (eg. most `PATCH` and `PUT` endpoints) require the `Content-Type: application/json` header set. +## User agent + +The API requires the `User-Agent` header to be set to a non-empty string. Not doing so will return a `400 Bad Request` with a JSON body. + +If you are developing an application exposed to the public, we would appreciate if your `User-Agent` uniquely identifies your application, and (if possible) provides some contact information for the developers - so that we are able to contact you if we notice your application doing something it shouldn't. + ## Authentication + Authentication is done with a simple "system token". You can get your system token by running `pk;token` using the Discord bot, either in a channel with the bot or in DMs. Then, pass this token in the `Authorization` HTTP header on requests that require it. Failure to do so on endpoints that require authentication will return a `401 Unauthorized`. diff --git a/services/web-proxy/main.go b/services/web-proxy/main.go index 2ff9c74d..919dbc1b 100644 --- a/services/web-proxy/main.go +++ b/services/web-proxy/main.go @@ -50,7 +50,9 @@ type ProxyHandler struct{} func (p ProxyHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { if r.Header.Get("User-Agent") == "" { // please set a valid user-agent - rw.WriteHeader(403) + rw.Header().Set("content-type", "application/json") + rw.WriteHeader(400) + rw.Write([]byte(`{"message":"A valid User-Agent header is required.","code":0}`)) return } From e9aaf7d54089904bd7184f0f9ef25a7393883639 Mon Sep 17 00:00:00 2001 From: sam Date: Mon, 27 Feb 2023 00:44:20 +0100 Subject: [PATCH 04/16] feat(docs): update pkgo import url (#522) --- docs/content/api/reference.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/api/reference.md b/docs/content/api/reference.md index f5ab10fa..20040331 100644 --- a/docs/content/api/reference.md +++ b/docs/content/api/reference.md @@ -66,7 +66,7 @@ The following API libraries have been created by members of our community. Pleas - **Python:** *PluralKit.py* ([PyPI](https://pypi.org/project/pluralkit/) | [Docs](https://pluralkit.readthedocs.io/en/latest/source/quickstart.html) | [Source code](https://github.com/almonds0166/pluralkit.py)) - **JavaScript:** *pkapi.js* ([npmjs](https://npmjs.com/package/pkapi.js) | [Docs](https://github.com/greysdawn/pk.js/wiki) | [Source code](https://github.com/greysdawn/pk.js)) -- **Golang:** *pkgo* (install: `go get github.com/starshine-sys/pkgo` | [Docs (godoc)](https://godocs.io/github.com/starshine-sys/pkgo) | [Docs (pkg.go.dev)](https://pkg.go.dev/github.com/starshine-sys/pkgo) | [Source code](https://github.com/starshine-sys/pkgo)) +- **Golang:** *pkgo* (install: `go get github.com/starshine-sys/pkgo/v2` | [Docs (godoc)](https://godocs.io/github.com/starshine-sys/pkgo/v2) | [Docs (pkg.go.dev)](https://pkg.go.dev/github.com/starshine-sys/pkgo/v2) | [Source code](https://github.com/starshine-sys/pkgo)) - **Kotlin:** *Plural.kt* ([Maven Repository](https://maven.proxyfox.dev/dev/proxyfox/pluralkt) | [Source code](https://github.com/The-ProxyFox-Group/Plural.kt)) Do let us know in the support server if you made a new library and would like to see it listed here! From e0c4b23f5e4a2d01be3cc03adc2dbe0651b7bba1 Mon Sep 17 00:00:00 2001 From: Jake Fulmine Date: Tue, 28 Feb 2023 00:12:31 +0100 Subject: [PATCH 05/16] feat(dashboard): add system recovery section --- dashboard/src/routes/Settings/Settings.svelte | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/dashboard/src/routes/Settings/Settings.svelte b/dashboard/src/routes/Settings/Settings.svelte index fe4603ce..f018c0b9 100644 --- a/dashboard/src/routes/Settings/Settings.svelte +++ b/dashboard/src/routes/Settings/Settings.svelte @@ -2,12 +2,15 @@ import { Card, CardHeader, CardBody, Container, Row, Col, CardTitle, Tooltip, Button } from 'sveltestrap'; import Toggle from 'svelte-toggle'; import { autoresize } from 'svelte-textarea-autoresize'; + import FaAmbulance from 'svelte-icons/fa/FaAmbulance.svelte' import FaCogs from 'svelte-icons/fa/FaCogs.svelte' import type { Config } from '../../api/types'; import api from '../../api'; let savedSettings = JSON.parse(localStorage.getItem("pk-settings")); let apiConfig: Config = JSON.parse(localStorage.getItem("pk-config")); + let token = localStorage.getItem("pk-token"); + let showToken = false; let settings = { appearance: { @@ -40,6 +43,7 @@ else document.getElementById("app").classList.remove("dyslexic"); } + const revealToken = () => showToken = !showToken; @@ -123,6 +127,36 @@ {/if} + {#if token} + + + + + +
+ +
Recovery +
+
+ +

If you've lost access to your discord account, you can retrieve your token here.

+

Send a direct message to a staff member (a helper, moderator or developer in the support server), they can recover your system with this token.

+ + {#if showToken} + + + {token} + + + + + + {/if} +
+
+ +
+ {/if}
From 7e606ca8b2c8a66bd18d9e380983eaab02015c6f Mon Sep 17 00:00:00 2001 From: spiral Date: Tue, 28 Feb 2023 15:44:07 +0000 Subject: [PATCH 06/16] fix(dashboard): set up replaceAll polyfill correctly --- dashboard/src/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/src/main.ts b/dashboard/src/main.ts index d09c1052..bdf470ef 100644 --- a/dashboard/src/main.ts +++ b/dashboard/src/main.ts @@ -2,7 +2,7 @@ import * as Sentry from "@sentry/browser"; import { Integrations } from "@sentry/tracing"; // polyfill for replaceAll -import * as replaceAll from 'core-js-pure/es/string/virtual/replace-all.js'; +import replaceAll from 'core-js-pure/es/string/virtual/replace-all.js'; if (!String.prototype.replaceAll) String.prototype.replaceAll = replaceAll; From 7fffb7f65af2f1a37df8c759b3f7e56f02c4625c Mon Sep 17 00:00:00 2001 From: Iris System Date: Wed, 1 Mar 2023 06:11:08 +1300 Subject: [PATCH 07/16] fix(web-proxy): move UA check into API block, small clean up --- services/web-proxy/main.go | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/services/web-proxy/main.go b/services/web-proxy/main.go index 919dbc1b..1756610c 100644 --- a/services/web-proxy/main.go +++ b/services/web-proxy/main.go @@ -48,14 +48,6 @@ func init() { type ProxyHandler struct{} func (p ProxyHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { - if r.Header.Get("User-Agent") == "" { - // please set a valid user-agent - rw.Header().Set("content-type", "application/json") - rw.WriteHeader(400) - rw.Write([]byte(`{"message":"A valid User-Agent header is required.","code":0}`)) - return - } - remote, ok := remotes[r.Host] if !ok { // unknown domains redirect to landing page @@ -65,7 +57,7 @@ func (p ProxyHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { if r.Host == "api.pluralkit.me" { // root - if r.URL.Path == "" { + if r.URL.Path == "" || r.URL.Path == "/" { // api root path redirects to docs http.Redirect(rw, r, "https://pluralkit.me/api/", http.StatusFound) return @@ -78,13 +70,16 @@ func (p ProxyHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { rw.Header().Add("Access-Control-Allow-Headers", "Content-Type, Authorization, sentry-trace, User-Agent") rw.Header().Add("Access-Control-Max-Age", "86400") - if r.Method == http.MethodOptions { - rw.WriteHeader(200) + if r.Header.Get("User-Agent") == "" { + // please set a valid user-agent + rw.Header().Set("content-type", "application/json") + rw.WriteHeader(400) + rw.Write([]byte(`{"message":"A valid User-Agent header is required.","code":0}`)) return } - if r.URL.Path == "/" { - http.Redirect(rw, r, "https://pluralkit.me/api", http.StatusFound) + if r.Method == http.MethodOptions { + rw.WriteHeader(200) return } @@ -92,9 +87,11 @@ func (p ProxyHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { rw.Header().Set("content-type", "application/json") rw.WriteHeader(410) rw.Write([]byte(`{"message":"Unsupported API version","code":0}`)) + return } if is_trying_to_use_v1_path_on_v2(r.URL.Path) { + rw.Header().Set("content-type", "application/json") rw.WriteHeader(400) rw.Write([]byte(`{"message":"Invalid path for API version","code":0}`)) return From ccb89f50e96861d5c49e7a31277d3f0bf1b3afba Mon Sep 17 00:00:00 2001 From: the iris system Date: Thu, 2 Mar 2023 06:11:35 +1300 Subject: [PATCH 08/16] feat(bot): allow separate member avatars for proxied messages (#523) This allows for using one avatar for the member card, and a different avatar for proxied messages - so that users can set the main avatar to a "full" version of their avatar, and the "proxy" avatar to a cropped version. --- PluralKit.Bot/CommandMeta/CommandTree.cs | 2 + PluralKit.Bot/Commands/MemberAvatar.cs | 122 +++++++++++++----- PluralKit.Bot/Services/EmbedService.cs | 5 +- .../Database/Functions/ProxyMember.cs | 4 +- .../Database/Functions/functions.sql | 24 ++-- PluralKit.Core/Database/Migrations/33.sql | 6 + .../Database/Utils/DatabaseMigrator.cs | 2 +- PluralKit.Core/Models/PKMember.cs | 5 + PluralKit.Core/Models/Patch/MemberPatch.cs | 8 ++ docs/content/api/models.md | 1 + docs/content/command-list.md | 1 + docs/content/user-guide.md | 7 + 12 files changed, 138 insertions(+), 49 deletions(-) create mode 100644 PluralKit.Core/Database/Migrations/33.sql diff --git a/PluralKit.Bot/CommandMeta/CommandTree.cs b/PluralKit.Bot/CommandMeta/CommandTree.cs index 0d5eb3d7..1a3b77d4 100644 --- a/PluralKit.Bot/CommandMeta/CommandTree.cs +++ b/PluralKit.Bot/CommandMeta/CommandTree.cs @@ -304,6 +304,8 @@ public partial class CommandTree await ctx.Execute(MemberDelete, m => m.Delete(ctx, target)); else if (ctx.Match("avatar", "profile", "picture", "icon", "image", "pfp", "pic")) await ctx.Execute(MemberAvatar, m => m.Avatar(ctx, target)); + else if (ctx.Match("proxyavatar", "proxypfp", "webhookavatar", "webhookpfp", "pa")) + await ctx.Execute(MemberAvatar, m => m.WebhookAvatar(ctx, target)); else if (ctx.Match("banner", "splash", "cover")) await ctx.Execute(MemberBannerImage, m => m.BannerImage(ctx, target)); else if (ctx.Match("group", "groups")) diff --git a/PluralKit.Bot/Commands/MemberAvatar.cs b/PluralKit.Bot/Commands/MemberAvatar.cs index cce0dd9e..bcf1b3c0 100644 --- a/PluralKit.Bot/Commands/MemberAvatar.cs +++ b/PluralKit.Bot/Commands/MemberAvatar.cs @@ -15,10 +15,10 @@ public class MemberAvatar _client = client; } - private async Task AvatarClear(AvatarLocation location, Context ctx, PKMember target, MemberGuildSettings? mgs) + private async Task AvatarClear(MemberAvatarLocation location, Context ctx, PKMember target, MemberGuildSettings? mgs) { await UpdateAvatar(location, ctx, target, null); - if (location == AvatarLocation.Server) + if (location == MemberAvatarLocation.Server) { if (target.AvatarUrl != null) await ctx.Reply( @@ -26,6 +26,14 @@ public class MemberAvatar else await ctx.Reply($"{Emojis.Success} Member server avatar cleared. This member now has no avatar."); } + else if (location == MemberAvatarLocation.MemberWebhook) + { + if (mgs?.AvatarUrl != null) + await ctx.Reply( + $"{Emojis.Success} Member proxy avatar cleared. Note that this member has a server-specific avatar set here, type `pk;member {target.Reference(ctx)} serveravatar clear` if you wish to clear that too."); + else + await ctx.Reply($"{Emojis.Success} Member proxy avatar cleared. This member will now use the main avatar for proxied messages."); + } else { if (mgs?.AvatarUrl != null) @@ -36,18 +44,26 @@ public class MemberAvatar } } - private async Task AvatarShow(AvatarLocation location, Context ctx, PKMember target, + private async Task AvatarShow(MemberAvatarLocation location, Context ctx, PKMember target, MemberGuildSettings? guildData) { // todo: this privacy code is really confusing // for now, we skip privacy flag/config parsing for this, but it would be good to fix that at some point - var currentValue = location == AvatarLocation.Member ? target.AvatarUrl : guildData?.AvatarUrl; - var canAccess = location != AvatarLocation.Member || + var currentValue = location switch + { + MemberAvatarLocation.Server => guildData?.AvatarUrl, + MemberAvatarLocation.MemberWebhook => target.WebhookAvatarUrl, + MemberAvatarLocation.Member => target.AvatarUrl, + _ => throw new ArgumentOutOfRangeException(nameof(location)) + }; + + var canAccess = location == MemberAvatarLocation.Server || target.AvatarPrivacy.CanAccess(ctx.DirectLookupContextFor(target.System)); + if (string.IsNullOrEmpty(currentValue) || !canAccess) { - if (location == AvatarLocation.Member) + if (location == MemberAvatarLocation.Member) { if (target.System == ctx.System?.Id) throw new PKSyntaxError( @@ -55,19 +71,24 @@ public class MemberAvatar throw new PKError("This member does not have an avatar set."); } - if (location == AvatarLocation.Server) + if (location == MemberAvatarLocation.MemberWebhook) + throw new PKError( + $"This member does not have a proxy avatar set. Type `pk;member {target.Reference(ctx)} avatar` to see their global avatar."); + + if (location == MemberAvatarLocation.Server) throw new PKError( $"This member does not have a server avatar set. Type `pk;member {target.Reference(ctx)} avatar` to see their global avatar."); } - var field = location == AvatarLocation.Server ? $"server avatar (for {ctx.Guild.Name})" : "avatar"; - var cmd = location == AvatarLocation.Server ? "serveravatar" : "avatar"; + var field = location.Name(); + if (location == MemberAvatarLocation.Server) + field += $" (for {ctx.Guild.Name})"; var eb = new EmbedBuilder() .Title($"{target.NameFor(ctx)}'s {field}") .Image(new Embed.EmbedImage(currentValue?.TryGetCleanCdnUrl())); if (target.System == ctx.System?.Id) - eb.Description($"To clear, use `pk;member {target.Reference(ctx)} {cmd} clear`."); + eb.Description($"To clear, use `pk;member {target.Reference(ctx)} {location.Command()} clear`."); await ctx.Reply(embed: eb.Build()); } @@ -75,7 +96,7 @@ public class MemberAvatar { ctx.CheckGuildContext(); var guildData = await ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id); - await AvatarCommandTree(AvatarLocation.Server, ctx, target, guildData); + await AvatarCommandTree(MemberAvatarLocation.Server, ctx, target, guildData); } public async Task Avatar(Context ctx, PKMember target) @@ -84,16 +105,23 @@ public class MemberAvatar ? await ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id) : null; - await AvatarCommandTree(AvatarLocation.Member, ctx, target, guildData); + await AvatarCommandTree(MemberAvatarLocation.Member, ctx, target, guildData); } - private async Task AvatarCommandTree(AvatarLocation location, Context ctx, PKMember target, + public async Task WebhookAvatar(Context ctx, PKMember target) + { + var guildData = ctx.Guild != null + ? await ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id) + : null; + + await AvatarCommandTree(MemberAvatarLocation.MemberWebhook, ctx, target, guildData); + } + + private async Task AvatarCommandTree(MemberAvatarLocation location, Context ctx, PKMember target, MemberGuildSettings? guildData) { // First, see if we need to *clear* - if (ctx.MatchClear() && await ctx.ConfirmClear(location == AvatarLocation.Server - ? "this member's server avatar" - : "this member's avatar")) + if (ctx.MatchClear() && await ctx.ConfirmClear("this member's " + location.Name())) { ctx.CheckSystem().CheckOwnMember(target); await AvatarClear(location, ctx, target, guildData); @@ -115,33 +143,30 @@ public class MemberAvatar await PrintResponse(location, ctx, target, avatarArg.Value, guildData); } - private Task PrintResponse(AvatarLocation location, Context ctx, PKMember target, ParsedImage avatar, + private Task PrintResponse(MemberAvatarLocation location, Context ctx, PKMember target, ParsedImage avatar, MemberGuildSettings? targetGuildData) { - var typeFrag = location switch - { - AvatarLocation.Server => "server avatar", - AvatarLocation.Member => "avatar", - _ => throw new ArgumentOutOfRangeException(nameof(location)) - }; - var serverFrag = location switch { - AvatarLocation.Server => + MemberAvatarLocation.Server => $" This avatar will now be used when proxying in this server (**{ctx.Guild.Name}**).", - AvatarLocation.Member when targetGuildData?.AvatarUrl != null => + MemberAvatarLocation.Member when targetGuildData?.AvatarUrl != null => $"\n{Emojis.Note} Note that this member *also* has a server-specific avatar set in this server (**{ctx.Guild.Name}**), and thus changing the global avatar will have no effect here.", + MemberAvatarLocation.MemberWebhook when targetGuildData?.AvatarUrl != null => + $" This avatar will now be used for this member's proxied messages, instead of their main avatar.\n{Emojis.Note} Note that this member *also* has a server-specific avatar set in this server (**{ctx.Guild.Name}**), and thus changing the global avatar will have no effect here.", + MemberAvatarLocation.MemberWebhook => + $" This avatar will now be used for this member's proxied messages, instead of their main avatar.", _ => "" }; var msg = avatar.Source switch { AvatarSource.User => - $"{Emojis.Success} Member {typeFrag} changed to {avatar.SourceUser?.Username}'s avatar!{serverFrag}\n{Emojis.Warn} If {avatar.SourceUser?.Username} changes their avatar, the member's avatar will need to be re-set.", + $"{Emojis.Success} Member {location.Name()} changed to {avatar.SourceUser?.Username}'s avatar!{serverFrag}\n{Emojis.Warn} If {avatar.SourceUser?.Username} changes their avatar, the member's avatar will need to be re-set.", AvatarSource.Url => - $"{Emojis.Success} Member {typeFrag} changed to the image at the given URL.{serverFrag}", + $"{Emojis.Success} Member {location.Name()} changed to the image at the given URL.{serverFrag}", AvatarSource.Attachment => - $"{Emojis.Success} Member {typeFrag} changed to attached image.{serverFrag}\n{Emojis.Warn} If you delete the message containing the attachment, the avatar will stop working.", + $"{Emojis.Success} Member {location.Name()} changed to attached image.{serverFrag}\n{Emojis.Warn} If you delete the message containing the attachment, the avatar will stop working.", _ => throw new ArgumentOutOfRangeException() }; @@ -152,18 +177,49 @@ public class MemberAvatar : ctx.Reply(msg); } - private Task UpdateAvatar(AvatarLocation location, Context ctx, PKMember target, string? url) + private Task UpdateAvatar(MemberAvatarLocation location, Context ctx, PKMember target, string? url) { switch (location) { - case AvatarLocation.Server: + case MemberAvatarLocation.Server: return ctx.Repository.UpdateMemberGuild(target.Id, ctx.Guild.Id, new MemberGuildPatch { AvatarUrl = url }); - case AvatarLocation.Member: + case MemberAvatarLocation.Member: return ctx.Repository.UpdateMember(target.Id, new MemberPatch { AvatarUrl = url }); + case MemberAvatarLocation.MemberWebhook: + return ctx.Repository.UpdateMember(target.Id, new MemberPatch { WebhookAvatarUrl = url }); default: throw new ArgumentOutOfRangeException($"Unknown avatar location {location}"); } } +} +internal enum MemberAvatarLocation +{ + Member, + MemberWebhook, + Server, +} - private enum AvatarLocation { Member, Server } +internal static class MemberAvatarLocationExt +{ + public static string Name(this MemberAvatarLocation location) + { + return location switch + { + MemberAvatarLocation.Server => "server avatar", + MemberAvatarLocation.MemberWebhook => "proxy avatar", + MemberAvatarLocation.Member => "avatar", + _ => throw new ArgumentOutOfRangeException(nameof(location)) + }; + } + + public static string Command(this MemberAvatarLocation location) + { + return location switch + { + MemberAvatarLocation.Server => "serveravatar", + MemberAvatarLocation.MemberWebhook => "proxyavatar", + MemberAvatarLocation.Member => "avatar", + _ => throw new ArgumentOutOfRangeException(nameof(location)) + }; + } } \ No newline at end of file diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index fb671565..0a8c56b5 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -135,7 +135,7 @@ public class EmbedService // sometimes Discord will just... not return the avatar hash with webhook messages var avatar = proxiedMessage.Author.Avatar != null ? proxiedMessage.Author.AvatarUrl() - : member.AvatarFor(LookupContext.ByNonOwner); + : member.WebhookAvatarFor(LookupContext.ByNonOwner); var embed = new EmbedBuilder() .Author(new Embed.EmbedAuthor($"#{channelName}: {name}", IconUrl: avatar)) .Thumbnail(new Embed.EmbedThumbnail(avatar)) @@ -175,6 +175,7 @@ public class EmbedService var guildSettings = guild != null ? await _repo.GetMemberGuild(guild.Id, member.Id) : null; var guildDisplayName = guildSettings?.DisplayName; + var webhook_avatar = guildSettings?.AvatarUrl ?? member.WebhookAvatarFor(ctx) ?? member.AvatarFor(ctx); var avatar = guildSettings?.AvatarUrl ?? member.AvatarFor(ctx); var groups = await _repo.GetMemberGroups(member.Id) @@ -183,7 +184,7 @@ public class EmbedService .ToListAsync(); var eb = new EmbedBuilder() - .Author(new Embed.EmbedAuthor(name, IconUrl: avatar.TryGetCleanCdnUrl(), Url: $"https://dash.pluralkit.me/profile/m/{member.Hid}")) + .Author(new Embed.EmbedAuthor(name, IconUrl: webhook_avatar.TryGetCleanCdnUrl(), Url: $"https://dash.pluralkit.me/profile/m/{member.Hid}")) // .WithColor(member.ColorPrivacy.CanAccess(ctx) ? color : DiscordUtils.Gray) .Color(color) .Footer(new Embed.EmbedFooter( diff --git a/PluralKit.Core/Database/Functions/ProxyMember.cs b/PluralKit.Core/Database/Functions/ProxyMember.cs index c0d41524..380110a7 100644 --- a/PluralKit.Core/Database/Functions/ProxyMember.cs +++ b/PluralKit.Core/Database/Functions/ProxyMember.cs @@ -23,9 +23,9 @@ public class ProxyMember public string Name { get; } = ""; public string? ServerAvatar { get; } + public string? WebhookAvatar { get; } public string? Avatar { get; } - public bool AllowAutoproxy { get; } public string? Color { get; } @@ -42,5 +42,5 @@ public class ProxyMember return memberName; } - public string? ProxyAvatar(MessageContext ctx) => ServerAvatar ?? Avatar ?? ctx.SystemAvatar; + public string? ProxyAvatar(MessageContext ctx) => ServerAvatar ?? WebhookAvatar ?? Avatar ?? ctx.SystemAvatar; } \ No newline at end of file diff --git a/PluralKit.Core/Database/Functions/functions.sql b/PluralKit.Core/Database/Functions/functions.sql index 0b9f3a76..a49fac8f 100644 --- a/PluralKit.Core/Database/Functions/functions.sql +++ b/PluralKit.Core/Database/Functions/functions.sql @@ -1,4 +1,4 @@ -create function message_context(account_id bigint, guild_id bigint, channel_id bigint) +create function message_context(account_id bigint, guild_id bigint, channel_id bigint) returns table ( system_id int, log_channel bigint, @@ -67,6 +67,7 @@ create function proxy_members(account_id bigint, guild_id bigint) name text, server_avatar text, + webhook_avatar text, avatar text, color char(6), @@ -76,22 +77,23 @@ create function proxy_members(account_id bigint, guild_id bigint) as $$ select -- Basic data - members.id as id, - members.proxy_tags as proxy_tags, - members.keep_proxy as keep_proxy, + members.id as id, + members.proxy_tags as proxy_tags, + members.keep_proxy as keep_proxy, -- Name info - member_guild.display_name as server_name, - members.display_name as display_name, - members.name as name, + member_guild.display_name as server_name, + members.display_name as display_name, + members.name as name, -- Avatar info - member_guild.avatar_url as server_avatar, - members.avatar_url as avatar, + member_guild.avatar_url as server_avatar, + members.webhook_avatar_url as webhook_avatar, + members.avatar_url as avatar, - members.color as color, + members.color as color, - members.allow_autoproxy as allow_autoproxy + members.allow_autoproxy as allow_autoproxy from accounts inner join systems on systems.id = accounts.system inner join members on members.system = systems.id diff --git a/PluralKit.Core/Database/Migrations/33.sql b/PluralKit.Core/Database/Migrations/33.sql new file mode 100644 index 00000000..fc648ff6 --- /dev/null +++ b/PluralKit.Core/Database/Migrations/33.sql @@ -0,0 +1,6 @@ +-- database version 33 +-- add webhook_avatar_url to system members + +alter table members add column webhook_avatar_url text; + +update info set schema_version = 33; \ No newline at end of file diff --git a/PluralKit.Core/Database/Utils/DatabaseMigrator.cs b/PluralKit.Core/Database/Utils/DatabaseMigrator.cs index 10183fdd..ff6efb4b 100644 --- a/PluralKit.Core/Database/Utils/DatabaseMigrator.cs +++ b/PluralKit.Core/Database/Utils/DatabaseMigrator.cs @@ -9,7 +9,7 @@ namespace PluralKit.Core; internal class DatabaseMigrator { private const string RootPath = "PluralKit.Core.Database"; // "resource path" root for SQL files - private const int TargetSchemaVersion = 32; + private const int TargetSchemaVersion = 33; private readonly ILogger _logger; public DatabaseMigrator(ILogger logger) diff --git a/PluralKit.Core/Models/PKMember.cs b/PluralKit.Core/Models/PKMember.cs index bbae8440..ede4368c 100644 --- a/PluralKit.Core/Models/PKMember.cs +++ b/PluralKit.Core/Models/PKMember.cs @@ -39,6 +39,7 @@ public class PKMember public Guid Uuid { get; private set; } public SystemId System { get; private set; } public string Color { get; private set; } + public string WebhookAvatarUrl { get; private set; } public string AvatarUrl { get; private set; } public string BannerImage { get; private set; } public string Name { get; private set; } @@ -90,6 +91,9 @@ public static class PKMemberExt public static string AvatarFor(this PKMember member, LookupContext ctx) => member.AvatarPrivacy.Get(ctx, member.AvatarUrl.TryGetCleanCdnUrl()); + public static string WebhookAvatarFor(this PKMember member, LookupContext ctx) => + member.AvatarPrivacy.Get(ctx, (member.WebhookAvatarUrl ?? member.AvatarUrl).TryGetCleanCdnUrl()); + public static string DescriptionFor(this PKMember member, LookupContext ctx) => member.DescriptionPrivacy.Get(ctx, member.Description); @@ -128,6 +132,7 @@ public static class PKMemberExt o.Add("birthday", member.BirthdayFor(ctx)?.FormatExport()); o.Add("pronouns", member.PronounsFor(ctx)); o.Add("avatar_url", member.AvatarFor(ctx).TryGetCleanCdnUrl()); + o.Add("webhook_avatar_url", member.WebhookAvatarFor(ctx).TryGetCleanCdnUrl()); o.Add("banner", member.DescriptionPrivacy.Get(ctx, member.BannerImage).TryGetCleanCdnUrl()); o.Add("description", member.DescriptionFor(ctx)); o.Add("created", member.CreatedFor(ctx)?.FormatExport()); diff --git a/PluralKit.Core/Models/Patch/MemberPatch.cs b/PluralKit.Core/Models/Patch/MemberPatch.cs index da02557d..4101a7c6 100644 --- a/PluralKit.Core/Models/Patch/MemberPatch.cs +++ b/PluralKit.Core/Models/Patch/MemberPatch.cs @@ -12,6 +12,7 @@ public class MemberPatch: PatchObject public Partial Name { get; set; } public Partial Hid { get; set; } public Partial DisplayName { get; set; } + public Partial WebhookAvatarUrl { get; set; } public Partial AvatarUrl { get; set; } public Partial BannerImage { get; set; } public Partial Color { get; set; } @@ -34,6 +35,7 @@ public class MemberPatch: PatchObject .With("name", Name) .With("hid", Hid) .With("display_name", DisplayName) + .With("webhook_avatar_url", WebhookAvatarUrl) .With("avatar_url", AvatarUrl) .With("banner_image", BannerImage) .With("color", Color) @@ -62,6 +64,9 @@ public class MemberPatch: PatchObject if (AvatarUrl.Value != null) AssertValid(AvatarUrl.Value, "avatar_url", Limits.MaxUriLength, s => MiscUtils.TryMatchUri(s, out var avatarUri)); + if (WebhookAvatarUrl.Value != null) + AssertValid(WebhookAvatarUrl.Value, "webhook_avatar_url", Limits.MaxUriLength, + s => MiscUtils.TryMatchUri(s, out var webhookAvatarUri)); if (BannerImage.Value != null) AssertValid(BannerImage.Value, "banner", Limits.MaxUriLength, s => MiscUtils.TryMatchUri(s, out var bannerUri)); @@ -93,6 +98,7 @@ public class MemberPatch: PatchObject if (o.ContainsKey("name")) patch.Name = o.Value("name"); if (o.ContainsKey("color")) patch.Color = o.Value("color").NullIfEmpty()?.ToLower(); if (o.ContainsKey("display_name")) patch.DisplayName = o.Value("display_name").NullIfEmpty(); + if (o.ContainsKey("webhook_avatar_url")) patch.WebhookAvatarUrl = o.Value("webhook_avatar_url").NullIfEmpty(); if (o.ContainsKey("avatar_url")) patch.AvatarUrl = o.Value("avatar_url").NullIfEmpty(); if (o.ContainsKey("banner")) patch.BannerImage = o.Value("banner").NullIfEmpty(); @@ -177,6 +183,8 @@ public class MemberPatch: PatchObject o.Add("display_name", DisplayName.Value); if (AvatarUrl.IsPresent) o.Add("avatar_url", AvatarUrl.Value); + if (WebhookAvatarUrl.IsPresent) + o.Add("webhook_avatar_url", WebhookAvatarUrl.Value); if (BannerImage.IsPresent) o.Add("banner", BannerImage.Value); if (Color.IsPresent) diff --git a/docs/content/api/models.md b/docs/content/api/models.md index 35d159fe..c1d9c0e9 100644 --- a/docs/content/api/models.md +++ b/docs/content/api/models.md @@ -46,6 +46,7 @@ Every PluralKit entity has two IDs: a short (5-character) ID and a longer UUID. |birthday|?string|`YYYY-MM-DD` format, 0004 hides the year| |pronouns|?string|100-character-limit| |avatar_url|?string|256-character limit, must be a publicly-accessible URL| +|webhook_avatar_url|?string|256-character limit, must be a publicly-accessible URL| |banner|?string|256-character limit, must be a publicly-accessible URL| |description|?string|1000-character limit| |created|?datetime|| diff --git a/docs/content/command-list.md b/docs/content/command-list.md index b96e3861..75b3c0ba 100644 --- a/docs/content/command-list.md +++ b/docs/content/command-list.md @@ -65,6 +65,7 @@ Some arguments indicate the use of specific Discord features. These include: - `pk;member servername ` - Changes the display name of a member, only in the current server. - `pk;member description [description]` - Changes the description of a member. - `pk;member avatar [avatar url|@mention|upload]` - Changes the avatar of a member. +- `pk;member proxyavatar [avatar url|@mention|upload]` - Changes the avatar used for proxied messages sent by a member. - `pk;member serveravatar [avatar url|@mention|upload]` - Changes the avatar of a member in a specific server. - `pk;member banner [image url|upload]` - Changes the banner image of a member. - `pk;member privacy` - Displays a members current privacy settings. diff --git a/docs/content/user-guide.md b/docs/content/user-guide.md index 41acceb6..bb4f47e9 100644 --- a/docs/content/user-guide.md +++ b/docs/content/user-guide.md @@ -229,6 +229,13 @@ To preview the current avatar (if one is set), use the command with no arguments To clear your avatar, use the subcommand `avatar clear` (eg. `pk;member John avatar clear`). +### Member proxy avatar +If you want your member to have a different avatar for proxies messages than the one displayed on the member card, you can set a proxy avatar. To do so, use the `pk;member proxyavatar` command, in the same way as the normal avatar command above: + + pk;member John avatar + pk;member John proxyavatar http://placebeard.it/512.jpg + pk;member "Craig Johnson" proxyavatar (with an attached image) + ### Member server avatar You can also set an avatar for a specific server. This will "override" the normal avatar, and will be used when proxying messages and looking up member cards in that server. To do so, use the `pk;member serveravatar` command, in the same way as the normal avatar command above: From 1da94706cb1edb9897d2a1efd9ec8372148508dd Mon Sep 17 00:00:00 2001 From: spiral Date: Sun, 5 Mar 2023 09:42:23 -0500 Subject: [PATCH 09/16] chore(web-proxy): update server ip --- services/web-proxy/main.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/services/web-proxy/main.go b/services/web-proxy/main.go index 1756610c..9a9edd58 100644 --- a/services/web-proxy/main.go +++ b/services/web-proxy/main.go @@ -24,9 +24,9 @@ var token2 string // todo: this shouldn't be in this repo var remotes = map[string]*httputil.ReverseProxy{ - "api.pluralkit.me": proxyTo("[fdaa:0:ae33:a7b:8dd7:0:a:202]:5000"), - "dash.pluralkit.me": proxyTo("[fdaa:0:ae33:a7b:8dd7:0:a:202]:8080"), - "sentry.pluralkit.me": proxyTo("[fdaa:0:ae33:a7b:8dd7:0:a:202]:9000"), + "api.pluralkit.me": proxyTo("[fdaa:0:ae33:a7b:8dd7:0:a:902]:5000"), + "dash.pluralkit.me": proxyTo("[fdaa:0:ae33:a7b:8dd7:0:a:902]:8080"), + "sentry.pluralkit.me": proxyTo("[fdaa:0:ae33:a7b:8dd7:0:a:902]:9000"), } func init() { From 0881996ca8581336d808d22e672ef2e1630e2225 Mon Sep 17 00:00:00 2001 From: foundationkitty <45774850+foundationkitty@users.noreply.github.com> Date: Mon, 6 Mar 2023 11:12:23 -0800 Subject: [PATCH 10/16] fix(docker-compose): add redis env var to api service (#528) - Add RedisAddr env variable to api service to match bot service --- docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.yml b/docker-compose.yml index ba2ad6f5..161fd133 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,7 @@ services: command: ["bin/PluralKit.API.dll"] environment: - "PluralKit:Database=Host=db;Username=postgres;Password=postgres;Database=postgres;Maximum Pool Size=1000" + - "PluralKit:RedisAddr=redis" ports: - "127.0.0.1:2838:5000" restart: unless-stopped From f58ada7fac3ea6fc1deb99133fcaded9a4875393 Mon Sep 17 00:00:00 2001 From: spiral Date: Thu, 9 Mar 2023 12:06:30 -0500 Subject: [PATCH 11/16] chore(web-proxy): update remote urls, add grafana --- services/web-proxy/main.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/services/web-proxy/main.go b/services/web-proxy/main.go index 9a9edd58..f8d70ced 100644 --- a/services/web-proxy/main.go +++ b/services/web-proxy/main.go @@ -24,9 +24,10 @@ var token2 string // todo: this shouldn't be in this repo var remotes = map[string]*httputil.ReverseProxy{ - "api.pluralkit.me": proxyTo("[fdaa:0:ae33:a7b:8dd7:0:a:902]:5000"), - "dash.pluralkit.me": proxyTo("[fdaa:0:ae33:a7b:8dd7:0:a:902]:8080"), - "sentry.pluralkit.me": proxyTo("[fdaa:0:ae33:a7b:8dd7:0:a:902]:9000"), + "api.pluralkit.me": proxyTo("[fdaa:0:ae33:a7b:8dd7:0:a:902]:5000"), + "dash.pluralkit.me": proxyTo("[fdaa:0:ae33:a7b:8dd7:0:a:902]:8080"), + "sentry.pluralkit.me": proxyTo("[fdaa:0:ae33:a7b:8dd7:0:a:902]:9000"), + "grafana.pluralkit.me": proxyTo("[fdaa:0:ae33:a7b:8dd7:0:a:802]:3000"), } func init() { From f6a9d693cd583fd795b98ba5450ba60cd292f74d Mon Sep 17 00:00:00 2001 From: spiral Date: Sat, 11 Mar 2023 17:45:22 -0500 Subject: [PATCH 12/16] fix(bot): ignore charset from discord cdn for export files --- PluralKit.Bot/Commands/ImportExport.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/PluralKit.Bot/Commands/ImportExport.cs b/PluralKit.Bot/Commands/ImportExport.cs index f189c8f7..99b3e680 100644 --- a/PluralKit.Bot/Commands/ImportExport.cs +++ b/PluralKit.Bot/Commands/ImportExport.cs @@ -47,6 +47,9 @@ public class ImportExport var response = await _client.GetAsync(url); if (!response.IsSuccessStatusCode) throw Errors.InvalidImportFile; + // hacky fix for discord api returning nonsense charsets sometimes + response.Content.Headers.Remove("content-type"); + response.Content.Headers.Add("content-type", "application/json; charset=UTF-8"); data = JsonConvert.DeserializeObject( await response.Content.ReadAsStringAsync(), _settings From 199d5927f52f3acafb10f2452632bca5f7d52342 Mon Sep 17 00:00:00 2001 From: V Morrison-Wood Date: Sun, 12 Mar 2023 01:51:02 +0000 Subject: [PATCH 13/16] fix(dash): Group delete prompts --- dashboard/src/components/group/Edit.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/src/components/group/Edit.svelte b/dashboard/src/components/group/Edit.svelte index 176c88a3..29857476 100644 --- a/dashboard/src/components/group/Edit.svelte +++ b/dashboard/src/components/group/Edit.svelte @@ -144,7 +144,7 @@ {:else} {/if} - Delete member + Delete group {#if deleteErr}{deleteErr}{/if} From 97be223173d6e1bde7fb3a226e6187e80eb1e6d7 Mon Sep 17 00:00:00 2001 From: Ouroboros <66399070+vmorrisonwood@users.noreply.github.com> Date: Sun, 12 Mar 2023 02:24:54 +0000 Subject: [PATCH 14/16] feat(bot): Switch subcommand aliases (#531) --- PluralKit.Bot/CommandMeta/CommandTree.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PluralKit.Bot/CommandMeta/CommandTree.cs b/PluralKit.Bot/CommandMeta/CommandTree.cs index 1a3b77d4..667c2af3 100644 --- a/PluralKit.Bot/CommandMeta/CommandTree.cs +++ b/PluralKit.Bot/CommandMeta/CommandTree.cs @@ -408,9 +408,9 @@ public partial class CommandTree { if (ctx.Match("out")) await ctx.Execute(SwitchOut, m => m.SwitchOut(ctx)); - else if (ctx.Match("move", "shift", "offset")) + else if (ctx.Match("move", "m", "shift", "offset")) await ctx.Execute(SwitchMove, m => m.SwitchMove(ctx)); - else if (ctx.Match("edit", "replace")) + else if (ctx.Match("edit", "e", "replace")) if (ctx.Match("out")) await ctx.Execute(SwitchEditOut, m => m.SwitchEditOut(ctx)); else From 5a248e26a239cf601e81454d18a77806cc47b86f Mon Sep 17 00:00:00 2001 From: repository <41586666+repository@users.noreply.github.com> Date: Mon, 13 Mar 2023 09:13:09 -0700 Subject: [PATCH 15/16] reduce dashboard bundle size by lazy loading highlight.js languages (#533) * lazyload hl.js languages * pin repository/discord-markdown --- dashboard/package.json | 3 +- dashboard/src/api/parse-markdown.ts | 421 ++++++++++++++++++ .../src/components/common/AwaitHtml.svelte | 11 + .../src/components/common/CardsHeader.svelte | 15 +- dashboard/src/components/group/Body.svelte | 21 +- .../src/components/group/CardView.svelte | 23 +- dashboard/src/components/member/Body.svelte | 28 +- .../src/components/member/CardView.svelte | 25 +- .../src/components/member/GroupEdit.svelte | 5 +- dashboard/src/components/system/Body.svelte | 28 +- dashboard/src/routes/Home.svelte | 9 +- dashboard/vite.config.js | 9 +- dashboard/yarn.lock | 20 +- 13 files changed, 530 insertions(+), 88 deletions(-) create mode 100644 dashboard/src/api/parse-markdown.ts create mode 100644 dashboard/src/components/common/AwaitHtml.svelte diff --git a/dashboard/package.json b/dashboard/package.json index 44939acd..7dfe415e 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -26,8 +26,9 @@ "bootstrap": "^5.1.3", "bootstrap-dark-5": "^1.1.3", "core-js-pure": "^3.23.4", - "discord-markdown": "^2.5.1", + "discord-markdown": "https://github.com/repository/discord-markdown#b9608feef6856c9baa68f96c932a25c1d2bc55c2", "gh-pages": "^3.2.3", + "highlight.js": "^11.7.0", "import": "^0.0.6", "moment": "^2.29.1", "sass": "^1.52.2", diff --git a/dashboard/src/api/parse-markdown.ts b/dashboard/src/api/parse-markdown.ts new file mode 100644 index 00000000..daf9b833 --- /dev/null +++ b/dashboard/src/api/parse-markdown.ts @@ -0,0 +1,421 @@ +import { toHTML } from 'discord-markdown'; +import hljs from 'highlight.js/lib/core'; +import parseTimestamps from './parse-timestamps'; + +const languages: Record Promise> = { + "1c": () => import("highlight.js/lib/languages/1c"), + "abnf": () => import("highlight.js/lib/languages/abnf"), + "accesslog": () => import("highlight.js/lib/languages/accesslog"), + "actionscript": () => import("highlight.js/lib/languages/actionscript"), + "ada": () => import("highlight.js/lib/languages/ada"), + "angelscript": () => import("highlight.js/lib/languages/angelscript"), + "apache": () => import("highlight.js/lib/languages/apache"), + "applescript": () => import("highlight.js/lib/languages/applescript"), + "arcade": () => import("highlight.js/lib/languages/arcade"), + "arduino": () => import("highlight.js/lib/languages/arduino"), + "armasm": () => import("highlight.js/lib/languages/armasm"), + "xml": () => import("highlight.js/lib/languages/xml"), + "asciidoc": () => import("highlight.js/lib/languages/asciidoc"), + "aspectj": () => import("highlight.js/lib/languages/aspectj"), + "autohotkey": () => import("highlight.js/lib/languages/autohotkey"), + "autoit": () => import("highlight.js/lib/languages/autoit"), + "avrasm": () => import("highlight.js/lib/languages/avrasm"), + "awk": () => import("highlight.js/lib/languages/awk"), + "axapta": () => import("highlight.js/lib/languages/axapta"), + "bash": () => import("highlight.js/lib/languages/bash"), + "basic": () => import("highlight.js/lib/languages/basic"), + "bnf": () => import("highlight.js/lib/languages/bnf"), + "brainfuck": () => import("highlight.js/lib/languages/brainfuck"), + "c": () => import("highlight.js/lib/languages/c"), + "cal": () => import("highlight.js/lib/languages/cal"), + "capnproto": () => import("highlight.js/lib/languages/capnproto"), + "ceylon": () => import("highlight.js/lib/languages/ceylon"), + "clean": () => import("highlight.js/lib/languages/clean"), + "clojure": () => import("highlight.js/lib/languages/clojure"), + "clojure-repl": () => import("highlight.js/lib/languages/clojure-repl"), + "cmake": () => import("highlight.js/lib/languages/cmake"), + "coffeescript": () => import("highlight.js/lib/languages/coffeescript"), + "coq": () => import("highlight.js/lib/languages/coq"), + "cos": () => import("highlight.js/lib/languages/cos"), + "cpp": () => import("highlight.js/lib/languages/cpp"), + "crmsh": () => import("highlight.js/lib/languages/crmsh"), + "crystal": () => import("highlight.js/lib/languages/crystal"), + "csharp": () => import("highlight.js/lib/languages/csharp"), + "csp": () => import("highlight.js/lib/languages/csp"), + "css": () => import("highlight.js/lib/languages/css"), + "d": () => import("highlight.js/lib/languages/d"), + "markdown": () => import("highlight.js/lib/languages/markdown"), + "dart": () => import("highlight.js/lib/languages/dart"), + "delphi": () => import("highlight.js/lib/languages/delphi"), + "diff": () => import("highlight.js/lib/languages/diff"), + "django": () => import("highlight.js/lib/languages/django"), + "dns": () => import("highlight.js/lib/languages/dns"), + "dockerfile": () => import("highlight.js/lib/languages/dockerfile"), + "dos": () => import("highlight.js/lib/languages/dos"), + "dsconfig": () => import("highlight.js/lib/languages/dsconfig"), + "dts": () => import("highlight.js/lib/languages/dts"), + "dust": () => import("highlight.js/lib/languages/dust"), + "ebnf": () => import("highlight.js/lib/languages/ebnf"), + "elixir": () => import("highlight.js/lib/languages/elixir"), + "elm": () => import("highlight.js/lib/languages/elm"), + "ruby": () => import("highlight.js/lib/languages/ruby"), + "erb": () => import("highlight.js/lib/languages/erb"), + "erlang-repl": () => import("highlight.js/lib/languages/erlang-repl"), + "erlang": () => import("highlight.js/lib/languages/erlang"), + "excel": () => import("highlight.js/lib/languages/excel"), + "fix": () => import("highlight.js/lib/languages/fix"), + "flix": () => import("highlight.js/lib/languages/flix"), + "fortran": () => import("highlight.js/lib/languages/fortran"), + "fsharp": () => import("highlight.js/lib/languages/fsharp"), + "gams": () => import("highlight.js/lib/languages/gams"), + "gauss": () => import("highlight.js/lib/languages/gauss"), + "gcode": () => import("highlight.js/lib/languages/gcode"), + "gherkin": () => import("highlight.js/lib/languages/gherkin"), + "glsl": () => import("highlight.js/lib/languages/glsl"), + "gml": () => import("highlight.js/lib/languages/gml"), + "go": () => import("highlight.js/lib/languages/go"), + "golo": () => import("highlight.js/lib/languages/golo"), + "gradle": () => import("highlight.js/lib/languages/gradle"), + "graphql": () => import("highlight.js/lib/languages/graphql"), + "groovy": () => import("highlight.js/lib/languages/groovy"), + "haml": () => import("highlight.js/lib/languages/haml"), + "handlebars": () => import("highlight.js/lib/languages/handlebars"), + "haskell": () => import("highlight.js/lib/languages/haskell"), + "haxe": () => import("highlight.js/lib/languages/haxe"), + "hsp": () => import("highlight.js/lib/languages/hsp"), + "http": () => import("highlight.js/lib/languages/http"), + "hy": () => import("highlight.js/lib/languages/hy"), + "inform7": () => import("highlight.js/lib/languages/inform7"), + "ini": () => import("highlight.js/lib/languages/ini"), + "irpf90": () => import("highlight.js/lib/languages/irpf90"), + "isbl": () => import("highlight.js/lib/languages/isbl"), + "java": () => import("highlight.js/lib/languages/java"), + "javascript": () => import("highlight.js/lib/languages/javascript"), + "jboss-cli": () => import("highlight.js/lib/languages/jboss-cli"), + "json": () => import("highlight.js/lib/languages/json"), + "julia": () => import("highlight.js/lib/languages/julia"), + "julia-repl": () => import("highlight.js/lib/languages/julia-repl"), + "kotlin": () => import("highlight.js/lib/languages/kotlin"), + "lasso": () => import("highlight.js/lib/languages/lasso"), + "latex": () => import("highlight.js/lib/languages/latex"), + "ldif": () => import("highlight.js/lib/languages/ldif"), + "leaf": () => import("highlight.js/lib/languages/leaf"), + "less": () => import("highlight.js/lib/languages/less"), + "lisp": () => import("highlight.js/lib/languages/lisp"), + "livecodeserver": () => import("highlight.js/lib/languages/livecodeserver"), + "livescript": () => import("highlight.js/lib/languages/livescript"), + "llvm": () => import("highlight.js/lib/languages/llvm"), + "lsl": () => import("highlight.js/lib/languages/lsl"), + "lua": () => import("highlight.js/lib/languages/lua"), + "makefile": () => import("highlight.js/lib/languages/makefile"), + "mathematica": () => import("highlight.js/lib/languages/mathematica"), + "matlab": () => import("highlight.js/lib/languages/matlab"), + "maxima": () => import("highlight.js/lib/languages/maxima"), + "mel": () => import("highlight.js/lib/languages/mel"), + "mercury": () => import("highlight.js/lib/languages/mercury"), + "mipsasm": () => import("highlight.js/lib/languages/mipsasm"), + "mizar": () => import("highlight.js/lib/languages/mizar"), + "perl": () => import("highlight.js/lib/languages/perl"), + "mojolicious": () => import("highlight.js/lib/languages/mojolicious"), + "monkey": () => import("highlight.js/lib/languages/monkey"), + "moonscript": () => import("highlight.js/lib/languages/moonscript"), + "n1ql": () => import("highlight.js/lib/languages/n1ql"), + "nestedtext": () => import("highlight.js/lib/languages/nestedtext"), + "nginx": () => import("highlight.js/lib/languages/nginx"), + "nim": () => import("highlight.js/lib/languages/nim"), + "nix": () => import("highlight.js/lib/languages/nix"), + "node-repl": () => import("highlight.js/lib/languages/node-repl"), + "nsis": () => import("highlight.js/lib/languages/nsis"), + "objectivec": () => import("highlight.js/lib/languages/objectivec"), + "ocaml": () => import("highlight.js/lib/languages/ocaml"), + "openscad": () => import("highlight.js/lib/languages/openscad"), + "oxygene": () => import("highlight.js/lib/languages/oxygene"), + "parser3": () => import("highlight.js/lib/languages/parser3"), + "pf": () => import("highlight.js/lib/languages/pf"), + "pgsql": () => import("highlight.js/lib/languages/pgsql"), + "php": () => import("highlight.js/lib/languages/php"), + "php-template": () => import("highlight.js/lib/languages/php-template"), + "plaintext": () => import("highlight.js/lib/languages/plaintext"), + "pony": () => import("highlight.js/lib/languages/pony"), + "powershell": () => import("highlight.js/lib/languages/powershell"), + "processing": () => import("highlight.js/lib/languages/processing"), + "profile": () => import("highlight.js/lib/languages/profile"), + "prolog": () => import("highlight.js/lib/languages/prolog"), + "properties": () => import("highlight.js/lib/languages/properties"), + "protobuf": () => import("highlight.js/lib/languages/protobuf"), + "puppet": () => import("highlight.js/lib/languages/puppet"), + "purebasic": () => import("highlight.js/lib/languages/purebasic"), + "python": () => import("highlight.js/lib/languages/python"), + "python-repl": () => import("highlight.js/lib/languages/python-repl"), + "q": () => import("highlight.js/lib/languages/q"), + "qml": () => import("highlight.js/lib/languages/qml"), + "r": () => import("highlight.js/lib/languages/r"), + "reasonml": () => import("highlight.js/lib/languages/reasonml"), + "rib": () => import("highlight.js/lib/languages/rib"), + "roboconf": () => import("highlight.js/lib/languages/roboconf"), + "routeros": () => import("highlight.js/lib/languages/routeros"), + "rsl": () => import("highlight.js/lib/languages/rsl"), + "ruleslanguage": () => import("highlight.js/lib/languages/ruleslanguage"), + "rust": () => import("highlight.js/lib/languages/rust"), + "sas": () => import("highlight.js/lib/languages/sas"), + "scala": () => import("highlight.js/lib/languages/scala"), + "scheme": () => import("highlight.js/lib/languages/scheme"), + "scilab": () => import("highlight.js/lib/languages/scilab"), + "scss": () => import("highlight.js/lib/languages/scss"), + "shell": () => import("highlight.js/lib/languages/shell"), + "smali": () => import("highlight.js/lib/languages/smali"), + "smalltalk": () => import("highlight.js/lib/languages/smalltalk"), + "sml": () => import("highlight.js/lib/languages/sml"), + "sqf": () => import("highlight.js/lib/languages/sqf"), + "sql": () => import("highlight.js/lib/languages/sql"), + "stan": () => import("highlight.js/lib/languages/stan"), + "stata": () => import("highlight.js/lib/languages/stata"), + "step21": () => import("highlight.js/lib/languages/step21"), + "stylus": () => import("highlight.js/lib/languages/stylus"), + "subunit": () => import("highlight.js/lib/languages/subunit"), + "swift": () => import("highlight.js/lib/languages/swift"), + "taggerscript": () => import("highlight.js/lib/languages/taggerscript"), + "yaml": () => import("highlight.js/lib/languages/yaml"), + "tap": () => import("highlight.js/lib/languages/tap"), + "tcl": () => import("highlight.js/lib/languages/tcl"), + "thrift": () => import("highlight.js/lib/languages/thrift"), + "tp": () => import("highlight.js/lib/languages/tp"), + "twig": () => import("highlight.js/lib/languages/twig"), + "typescript": () => import("highlight.js/lib/languages/typescript"), + "vala": () => import("highlight.js/lib/languages/vala"), + "vbnet": () => import("highlight.js/lib/languages/vbnet"), + "vbscript": () => import("highlight.js/lib/languages/vbscript"), + "vbscript-html": () => import("highlight.js/lib/languages/vbscript-html"), + "verilog": () => import("highlight.js/lib/languages/verilog"), + "vhdl": () => import("highlight.js/lib/languages/vhdl"), + "vim": () => import("highlight.js/lib/languages/vim"), + "wasm": () => import("highlight.js/lib/languages/wasm"), + "wren": () => import("highlight.js/lib/languages/wren"), + "x86asm": () => import("highlight.js/lib/languages/x86asm"), + "xl": () => import("highlight.js/lib/languages/xl"), + "xquery": () => import("highlight.js/lib/languages/xquery"), + "zephir": () => import("highlight.js/lib/languages/zephir"), +} + +// hljs.listLanguages().map(l => ([l, hljs.getLanguage(l).aliases])).filter(([, b]) => b).map(([n, a]) => a.map(al => ([al, n]))).flat().map(([a, n]) => `"${a}": languages["${n}"]`).join(",\n") +const aliases: Record = { + "as": languages["actionscript"], + "asc": languages["angelscript"], + "apacheconf": languages["apache"], + "osascript": languages["applescript"], + "ino": languages["arduino"], + "arm": languages["armasm"], + "html": languages["xml"], + "xhtml": languages["xml"], + "rss": languages["xml"], + "atom": languages["xml"], + "xjb": languages["xml"], + "xsd": languages["xml"], + "xsl": languages["xml"], + "plist": languages["xml"], + "wsf": languages["xml"], + "svg": languages["xml"], + "adoc": languages["asciidoc"], + "ahk": languages["autohotkey"], + "x++": languages["axapta"], + "sh": languages["bash"], + "bf": languages["brainfuck"], + "h": languages["c"], + "capnp": languages["capnproto"], + "icl": languages["clean"], + "dcl": languages["clean"], + "clj": languages["clojure"], + "edn": languages["clojure"], + "cmake.in": languages["cmake"], + "coffee": languages["coffeescript"], + "cson": languages["coffeescript"], + "iced": languages["coffeescript"], + "cls": languages["cos"], + "cc": languages["cpp"], + "c++": languages["cpp"], + "h++": languages["cpp"], + "hpp": languages["cpp"], + "hh": languages["cpp"], + "hxx": languages["cpp"], + "cxx": languages["cpp"], + "crm": languages["crmsh"], + "pcmk": languages["crmsh"], + "cr": languages["crystal"], + "cs": languages["csharp"], + "c#": languages["csharp"], + "md": languages["markdown"], + "mkdown": languages["markdown"], + "mkd": languages["markdown"], + "dpr": languages["delphi"], + "dfm": languages["delphi"], + "pas": languages["delphi"], + "pascal": languages["delphi"], + "patch": languages["diff"], + "jinja": languages["django"], + "bind": languages["dns"], + "zone": languages["dns"], + "docker": languages["dockerfile"], + "bat": languages["dos"], + "cmd": languages["dos"], + "dst": languages["dust"], + "ex": languages["elixir"], + "exs": languages["elixir"], + "rb": languages["ruby"], + "gemspec": languages["ruby"], + "podspec": languages["ruby"], + "thor": languages["ruby"], + "irb": languages["ruby"], + "erl": languages["erlang"], + "xlsx": languages["excel"], + "xls": languages["excel"], + "f90": languages["fortran"], + "f95": languages["fortran"], + "fs": languages["fsharp"], + "f#": languages["fsharp"], + "gms": languages["gams"], + "gss": languages["gauss"], + "nc": languages["gcode"], + "feature": languages["gherkin"], + "golang": languages["go"], + "gql": languages["graphql"], + "hbs": languages["handlebars"], + "html.hbs": languages["handlebars"], + "html.handlebars": languages["handlebars"], + "htmlbars": languages["handlebars"], + "hs": languages["haskell"], + "hx": languages["haxe"], + "https": languages["http"], + "hylang": languages["hy"], + "i7": languages["inform7"], + "toml": languages["ini"], + "jsp": languages["java"], + "js": languages["javascript"], + "jsx": languages["javascript"], + "mjs": languages["javascript"], + "cjs": languages["javascript"], + "wildfly-cli": languages["jboss-cli"], + "jldoctest": languages["julia-repl"], + "kt": languages["kotlin"], + "kts": languages["kotlin"], + "ls": languages["lasso"], + "lassoscript": languages["lasso"], + "tex": languages["latex"], + "mk": languages["makefile"], + "mak": languages["makefile"], + "make": languages["makefile"], + "mma": languages["mathematica"], + "wl": languages["mathematica"], + "m": languages["mercury"], + "moo": languages["mercury"], + "mips": languages["mipsasm"], + "pl": languages["perl"], + "pm": languages["perl"], + "moon": languages["moonscript"], + "nt": languages["nestedtext"], + "nginxconf": languages["nginx"], + "nixos": languages["nix"], + "mm": languages["objectivec"], + "objc": languages["objectivec"], + "obj-c": languages["objectivec"], + "obj-c++": languages["objectivec"], + "objective-c++": languages["objectivec"], + "ml": languages["ocaml"], + "scad": languages["openscad"], + "pf.conf": languages["pf"], + "postgres": languages["pgsql"], + "postgresql": languages["pgsql"], + "text": languages["plaintext"], + "txt": languages["plaintext"], + "pwsh": languages["powershell"], + "ps": languages["powershell"], + "ps1": languages["powershell"], + "pde": languages["processing"], + "pp": languages["puppet"], + "pb": languages["purebasic"], + "pbi": languages["purebasic"], + "py": languages["python"], + "gyp": languages["python"], + "ipython": languages["python"], + "pycon": languages["python-repl"], + "k": languages["q"], + "kdb": languages["q"], + "qt": languages["qml"], + "re": languages["reasonml"], + "graph": languages["roboconf"], + "instances": languages["roboconf"], + "mikrotik": languages["routeros"], + "rs": languages["rust"], + "scm": languages["scheme"], + "sci": languages["scilab"], + "console": languages["shell"], + "shellsession": languages["shell"], + "st": languages["smalltalk"], + "stanfuncs": languages["stan"], + "do": languages["stata"], + "ado": languages["stata"], + "p21": languages["step21"], + "step": languages["step21"], + "stp": languages["step21"], + "styl": languages["stylus"], + "yml": languages["yaml"], + "tk": languages["tcl"], + "craftcms": languages["twig"], + "ts": languages["typescript"], + "tsx": languages["typescript"], + "vb": languages["vbnet"], + "vbs": languages["vbscript"], + "v": languages["verilog"], + "sv": languages["verilog"], + "svh": languages["verilog"], + "tao": languages["xl"], + "xpath": languages["xquery"], + "xq": languages["xquery"], + "zep": languages["zephir"] +} + +interface ParseMarkdownOptions { + parseTimestamps?: boolean; + embed?: boolean; +} + +const parseMarkdown = async (raw: string, opts?: ParseMarkdownOptions) => { + if (opts?.parseTimestamps) { + raw = parseTimestamps(raw); + } + + const markdownUnparsed = toHTML(raw, { embed: opts?.embed }); + const markdownUnparsedDom = new DOMParser().parseFromString(markdownUnparsed, "text/html"); + + const codeBlocks = markdownUnparsedDom.querySelectorAll("pre code[data-code]"); + + const promies = Array.from(codeBlocks).map(async (codeBlock) => { + let code: string = window.atob(codeBlock.getAttribute("data-code")); + + codeBlock.classList.add("hljs"); + + const specifiedLanguage = codeBlock.getAttribute("data-code-language"); + const languageImportFn = languages[specifiedLanguage] ?? aliases[specifiedLanguage]; + + if (languageImportFn) { + if (!hljs.getLanguage(specifiedLanguage)) { + const languageImport = await languageImportFn(); + + hljs.registerLanguage(specifiedLanguage, languageImport.default); + } + + codeBlock.classList.add(specifiedLanguage); + codeBlock.innerHTML = hljs.highlight(code, {language: specifiedLanguage}).value; + } else { + codeBlock.textContent = code; + } + + codeBlock.removeAttribute("data-code"); + codeBlock.removeAttribute("data-code-language"); + }); + + await Promise.all(promies); + + return markdownUnparsedDom.body.innerHTML; +} + +export default parseMarkdown; \ No newline at end of file diff --git a/dashboard/src/components/common/AwaitHtml.svelte b/dashboard/src/components/common/AwaitHtml.svelte new file mode 100644 index 00000000..f4d2eae3 --- /dev/null +++ b/dashboard/src/components/common/AwaitHtml.svelte @@ -0,0 +1,11 @@ + + +{#await htmlPromise} + (loading...) +{:then html} + {@html html ?? ""} +{:catch error} + (failed to parse: {error?.message ?? String(error)}) +{/await} \ No newline at end of file diff --git a/dashboard/src/components/common/CardsHeader.svelte b/dashboard/src/components/common/CardsHeader.svelte index 5d029633..53b0f854 100644 --- a/dashboard/src/components/common/CardsHeader.svelte +++ b/dashboard/src/components/common/CardsHeader.svelte @@ -1,23 +1,24 @@