Преглед изворни кода

Merge branch 'main' into twilio

binwiederhier пре 2 година
родитељ
комит
69b01bc468

+ 1 - 0
docs/integrations.md

@@ -104,6 +104,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
 - [ntfy_on_a_chip](https://github.com/gergepalfi/ntfy_on_a_chip) - ESP8266 and ESP32 client code to communicate with ntfy
 - [ntfy-sdk](https://github.com/yukibtc/ntfy-sdk) - ntfy client library to send notifications (Rust)
 - [ntfy_ynh](https://github.com/YunoHost-Apps/ntfy_ynh) - ntfy app for YunoHost 
+- [woodpecker-ntfy](https://codeberg.org/l-x/woodpecker-ntfy)- Woodpecker CI plugin for sending ntfy notfication from a pipeline (Go)
 - [drone-ntfy](https://github.com/Clortox/drone-ntfy) - Drone.io plugin for sending ntfy notifications from a pipeline (Shell)
 - [ignition-ntfy-module](https://github.com/Kyvis-Labs/ignition-ntfy-module) - Adds support for sending notifications via a ntfy server to Ignition (Java)
 - [maubot-ntfy](https://gitlab.com/999eagle/maubot-ntfy) - Matrix bot to subscribe to ntfy topics and send messages to Matrix (Python)

+ 1 - 1
docs/publish.md

@@ -2702,7 +2702,7 @@ You can use ntfy to call a phone and **read the message out loud using text-to-s
 Similar to email notifications, this can be useful to blast-notify yourself on all possible channels, or to notify people that do not have 
 the ntfy app installed on their phone.
 
-Phone numbers have to be previously verified (via the web app). To forward a message as a phone call, pass a phone number
+Phone numbers have to be previously verified (via the web app). To forward a message as a voice call, pass a phone number
 in the `X-Call` header (or its alias: `Call`), prefixed with a plus sign and the country code, e.g. `+12223334444`. You may
 also simply pass `yes` as a value if you only have one verified phone number.
 

+ 5 - 1
docs/releases.md

@@ -1182,13 +1182,17 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
 
 **Features:**
 
-* Support for SMS and voice calls using Twilio (no ticket)
+* Support for text-to-speech style [phone calls](publish.md#phone-calls) using the `X-Call` header (no ticket)
+* Admin API to manage users and ACL, `v1/users` + `v1/users/access` ([#722](https://github.com/binwiederhier/ntfy/issues/722), thanks to [@CreativeWarlock](https://github.com/CreativeWarlock) for sponsoring this ticket)
 
 **Bug fixes + maintenance:**
 
 * Removed old ntfy website from ntfy entirely (no ticket)
+* Make emoji lookup for emails more efficient ([#725](https://github.com/binwiederhier/ntfy/pull/725), thanks to [@adamantike](https://github.com/adamantike))
 * Fix potential subscriber ID clash ([#712](https://github.com/binwiederhier/ntfy/issues/712), thanks to [@peterbourgon](https://github.com/peterbourgon) for reporting, and [@dropdevrahul](https://github.com/dropdevrahul) for fixing)
 * Support for `quoted-printable` in incoming emails ([#719](https://github.com/binwiederhier/ntfy/pull/719), thanks to [@Aerion](https://github.com/Aerion))
+* Attachments with filenames that are downloaded using a browser will now download with the proper filename ([#726](https://github.com/binwiederhier/ntfy/issues/726), thanks to [@un99known99](https://github.com/un99known99) for reporting, and [@wunter8](https://github.com/wunter8) for fixing)
+* Fix web app i18n issue in account preferences ([#730](https://github.com/binwiederhier/ntfy/issues/730), thanks to [@codebude](https://github.com/codebude) for reporting)
 
 ### ntfy Android app v1.16.1 (UNRELEASED)
 

+ 9 - 10
go.mod

@@ -14,12 +14,12 @@ require (
 	github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8
 	github.com/stretchr/testify v1.8.1
 	github.com/urfave/cli/v2 v2.25.3
-	golang.org/x/crypto v0.8.0
-	golang.org/x/oauth2 v0.7.0 // indirect
+	golang.org/x/crypto v0.9.0
+	golang.org/x/oauth2 v0.8.0 // indirect
 	golang.org/x/sync v0.2.0
 	golang.org/x/term v0.8.0
 	golang.org/x/time v0.3.0
-	google.golang.org/api v0.121.0
+	google.golang.org/api v0.122.0
 	gopkg.in/yaml.v2 v2.4.0
 )
 
@@ -28,15 +28,15 @@ require github.com/pkg/errors v0.9.1 // indirect
 require (
 	firebase.google.com/go/v4 v4.11.0
 	github.com/prometheus/client_golang v1.15.1
-	github.com/stripe/stripe-go/v74 v74.17.0
+	github.com/stripe/stripe-go/v74 v74.18.0
 )
 
 require (
-	cloud.google.com/go v0.110.1 // indirect
-	cloud.google.com/go/compute v1.19.1 // indirect
+	cloud.google.com/go v0.110.2 // indirect
+	cloud.google.com/go/compute v1.19.2 // indirect
 	cloud.google.com/go/compute/metadata v0.2.3 // indirect
-	cloud.google.com/go/iam v1.0.0 // indirect
-	cloud.google.com/go/longrunning v0.4.1 // indirect
+	cloud.google.com/go/iam v1.0.1 // indirect
+	cloud.google.com/go/longrunning v0.4.2 // indirect
 	github.com/AlekSi/pointer v1.2.0 // indirect
 	github.com/MicahParks/keyfunc v1.9.0 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
@@ -57,12 +57,11 @@ require (
 	github.com/prometheus/client_model v0.4.0 // indirect
 	github.com/prometheus/common v0.43.0 // indirect
 	github.com/prometheus/procfs v0.9.0 // indirect
-	github.com/rogpeppe/go-internal v1.10.0 // indirect
 	github.com/russross/blackfriday/v2 v2.1.0 // indirect
 	github.com/stretchr/objx v0.5.0 // indirect
 	github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
 	go.opencensus.io v0.24.0 // indirect
-	golang.org/x/net v0.9.0 // indirect
+	golang.org/x/net v0.10.0 // indirect
 	golang.org/x/sys v0.8.0 // indirect
 	golang.org/x/text v0.9.0 // indirect
 	golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect

+ 18 - 31
go.sum

@@ -1,19 +1,25 @@
 cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
 cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go v0.110.0 h1:Zc8gqp3+a9/Eyph2KDmcGaPtbKRIoqq4YTlL4NMD0Ys=
-cloud.google.com/go v0.110.0/go.mod h1:SJnCLqQ0FCFGSZMUNUf84MV3Aia54kn7pi8st7tMzaY=
 cloud.google.com/go v0.110.1 h1:oDJ19Fu9TX9Xs06iyCw4yifSqZ7JQ8BeuVHcTmWQlOA=
 cloud.google.com/go v0.110.1/go.mod h1:uc+V/WjzxQ7vpkxfJhgW4Q4axWXyfAerpQOuSNDZyFw=
+cloud.google.com/go v0.110.2 h1:sdFPBr6xG9/wkBbfhmUz/JmZC7X6LavQgcrVINrKiVA=
+cloud.google.com/go v0.110.2/go.mod h1:k04UEeEtb6ZBRTv3dZz4CeJC3jKGxyhl0sAiVVquxiw=
 cloud.google.com/go/compute v1.19.1 h1:am86mquDUgjGNWxiGn+5PGLbmgiWXlE/yNWpIpNvuXY=
 cloud.google.com/go/compute v1.19.1/go.mod h1:6ylj3a05WF8leseCdIf77NK0g1ey+nj5IKd5/kvShxE=
+cloud.google.com/go/compute v1.19.2 h1:GbJtPo8OKVHbVep8jvM57KidbYHxeE68LOVqouNLrDY=
+cloud.google.com/go/compute v1.19.2/go.mod h1:5f5a+iC1IriXYauaQ0EyQmEAEq9CGRnV5xJSQSlTV08=
 cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
 cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
 cloud.google.com/go/firestore v1.9.0 h1:IBlRyxgGySXu5VuW0RgGFlTtLukSnNkpDiEOMkQkmpA=
 cloud.google.com/go/firestore v1.9.0/go.mod h1:HMkjKHNTtRyZNiMzu7YAsLr9K3X2udY2AMwDaMEQiiE=
 cloud.google.com/go/iam v1.0.0 h1:hlQJMovyJJwYjZcTohUH4o1L8Z8kYz+E+W/zktiLCBc=
 cloud.google.com/go/iam v1.0.0/go.mod h1:ikbQ4f1r91wTmBmmOtBCOtuEOei6taatNXytzB7Cxew=
+cloud.google.com/go/iam v1.0.1 h1:lyeCAU6jpnVNrE9zGQkTl3WgNgK/X+uWwaw0kynZJMU=
+cloud.google.com/go/iam v1.0.1/go.mod h1:yR3tmSL8BcZB4bxByRv2jkSIahVmCtfKZwLYGBalRE8=
 cloud.google.com/go/longrunning v0.4.1 h1:v+yFJOfKC3yZdY6ZUI933pIYdhyhV8S3NpWrXWmg7jM=
 cloud.google.com/go/longrunning v0.4.1/go.mod h1:4iWDqhBZ70CvZ6BfETbvam3T8FMvLK+eFj0E6AaRQTo=
+cloud.google.com/go/longrunning v0.4.2 h1:WDKiiNXFTaQ6qz/G8FCOkuY9kJmOJGY67wPUC1M2RbE=
+cloud.google.com/go/longrunning v0.4.2/go.mod h1:OHrnaYyLUV6oqwh0xiS7e5sLQhP1m0QU9R+WhGDMgIQ=
 cloud.google.com/go/storage v1.30.1 h1:uOdMxAs8HExqBlnLtnQyP0YkvbiDpdGShGKtx6U/oNM=
 cloud.google.com/go/storage v1.30.1/go.mod h1:NfxhC0UJE1aXSx7CIIbCf7y9HKT7BiccwkR7+P7gN8E=
 firebase.google.com/go/v4 v4.11.0 h1:szjBoiF33A2FavRLIDZjW1mw+OsW/XAtHoYNIqWOjRk=
@@ -71,7 +77,6 @@ github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y
 github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
-github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
 github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
 github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
 github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
@@ -94,8 +99,6 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
 github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
-github.com/google/s2a-go v0.1.2 h1:WVtYAYuYxKeYajAmThMRYWP6K3wXkcqbGHeUgeubUHY=
-github.com/google/s2a-go v0.1.2/go.mod h1:OJpEgntRZo8ugHpF9hkoLJbS5dSI20XZeXJ9JVywLlM=
 github.com/google/s2a-go v0.1.3 h1:FAgZmpLl/SXurPEZyCMPBIiiYeTbqfjlbdnCNTAkbGE=
 github.com/google/s2a-go v0.1.3/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A=
 github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -121,26 +124,17 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/prometheus/client_golang v1.15.0 h1:5fCgGYogn0hFdhyhLbw7hEsWxufKtY9klyvdNfFlFhM=
-github.com/prometheus/client_golang v1.15.0/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk=
 github.com/prometheus/client_golang v1.15.1 h1:8tXpTmJbyH5lydzFPoxSIJ0J46jdh3tylbvM1xCv0LI=
 github.com/prometheus/client_golang v1.15.1/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk=
 github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4=
-github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
 github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY=
 github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
-github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM=
-github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc=
 github.com/prometheus/common v0.43.0 h1:iq+BVjvYLei5f27wiuNiB1DN6DYQkp1c8Bx0Vykh5us=
 github.com/prometheus/common v0.43.0/go.mod h1:NCvr5cQIh3Y/gy73/RdVtC9r8xxrxwJnB+2lB3BxrFc=
 github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI=
 github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY=
 github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
-github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
-github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
 github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
-github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
 github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -153,12 +147,10 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-github.com/stripe/stripe-go/v74 v74.15.0 h1:P3ZYrY4CdZeV8Pc/205utqjur+5gcTef+9hgtj8P8IY=
-github.com/stripe/stripe-go/v74 v74.15.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
 github.com/stripe/stripe-go/v74 v74.17.0 h1:qVWSzmADr6gudznuAcPjB9ewzgxfyIhBCkyTbkxJcCw=
 github.com/stripe/stripe-go/v74 v74.17.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
-github.com/urfave/cli/v2 v2.25.1 h1:zw8dSP7ghX0Gmm8vugrs6q9Ku0wzweqPyshy+syu9Gw=
-github.com/urfave/cli/v2 v2.25.1/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
+github.com/stripe/stripe-go/v74 v74.18.0 h1:ImSIoaVkTUozHxa21AhwHYBjwc8fVSJJJB1Q7oaXzIw=
+github.com/stripe/stripe-go/v74 v74.18.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
 github.com/urfave/cli/v2 v2.25.3 h1:VJkt6wvEBOoSjPFQvOkv6iWIrsJyCrKGtCtxXWwmGeY=
 github.com/urfave/cli/v2 v2.25.3/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
 github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
@@ -173,6 +165,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
 golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/crypto v0.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ=
 golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
+golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
+golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
 golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
@@ -190,23 +184,23 @@ golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81R
 golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM=
 golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
+golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.7.0 h1:qe6s0zUXlPX80/dITx3440hWZ7GwMwgDDyrSGTPJG/g=
 golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4=
+golang.org/x/oauth2 v0.8.0 h1:6dkIjl3j3LtZ/O3sTgZTMsLKSftL/B8Zgq4huOIIUu8=
+golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
-golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI=
 golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -217,17 +211,12 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
-golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/term v0.7.0 h1:BEvjmm5fURWqcfbSKTdpkDXYBrUS1c0m8agp14W48vQ=
-golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
 golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols=
 golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -252,10 +241,10 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
 golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
-google.golang.org/api v0.120.0 h1:TTmhTei0mkR+kiBSW2UzZmAbkTaBfUUzfchyXnzG9Hs=
-google.golang.org/api v0.120.0/go.mod h1:CrSvlNEFCFLae9ZUtL1z+61+rEBD7J/aCYwVYKZoWFU=
 google.golang.org/api v0.121.0 h1:8Oopoo8Vavxx6gt+sgs8s8/X60WBAtKQq6JqnkF+xow=
 google.golang.org/api v0.121.0/go.mod h1:gcitW0lvnyWjSp9nKxAbdHKIZ6vF4aajGueeslZOyms=
+google.golang.org/api v0.122.0 h1:zDobeejm3E7pEG1mNHvdxvjs5XJoCMzyNH+CmwL94Es=
+google.golang.org/api v0.122.0/go.mod h1:gcitW0lvnyWjSp9nKxAbdHKIZ6vF4aajGueeslZOyms=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
@@ -276,8 +265,6 @@ google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTp
 google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
 google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
 google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
-google.golang.org/grpc v1.54.0 h1:EhTqbhiYeixwWQtAEZAxmV9MGqcjEU2mFx52xCzNyag=
-google.golang.org/grpc v1.54.0/go.mod h1:PUSEXI6iWghWaB6lXM4knEgpJNu2qUcKfDtNci3EC2g=
 google.golang.org/grpc v1.55.0 h1:3Oj82/tFSCeUrRTg/5E/7d/W5A1tj6Ky1ABAuZuv5ag=
 google.golang.org/grpc v1.55.0/go.mod h1:iYEXKGkEBhg1PjZQvoYEVPTDkHo1/bjTnfwTeGONTY8=
 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=

+ 4 - 2
server/errors.go

@@ -106,8 +106,10 @@ var (
 	errHTTPBadRequestNotAPaidUser                    = &errHTTP{40027, http.StatusBadRequest, "invalid request: not a paid user", "", nil}
 	errHTTPBadRequestBillingRequestInvalid           = &errHTTP{40028, http.StatusBadRequest, "invalid request: not a valid billing request", "", nil}
 	errHTTPBadRequestBillingSubscriptionExists       = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", "", nil}
-	errHTTPBadRequestTwilioDisabled                  = &errHTTP{40030, http.StatusBadRequest, "invalid request: Calling is disabled", "https://ntfy.sh/docs/publish/#phone-calls", nil}
-	errHTTPBadRequestPhoneNumberInvalid              = &errHTTP{40031, http.StatusBadRequest, "invalid request: phone number invalid", "https://ntfy.sh/docs/publish/#phone-calls", nil}
+	errHTTPBadRequestTierInvalid                     = &errHTTP{40030, http.StatusBadRequest, "invalid request: tier does not exist", "", nil}
+	errHTTPBadRequestUserNotFound                    = &errHTTP{40031, http.StatusBadRequest, "invalid request: user does not exist", "", nil}
+	errHTTPBadRequestTwilioDisabled                  = &errHTTP{40032, http.StatusBadRequest, "invalid request: Calling is disabled", "https://ntfy.sh/docs/publish/#phone-calls", nil}
+	errHTTPBadRequestPhoneNumberInvalid              = &errHTTP{40033, http.StatusBadRequest, "invalid request: phone number invalid", "https://ntfy.sh/docs/publish/#phone-calls", nil}
 	errHTTPNotFound                                  = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
 	errHTTPUnauthorized                              = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
 	errHTTPForbidden                                 = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
server/mailer_emoji.json


+ 1857 - 0
server/mailer_emoji_map.json

@@ -0,0 +1,1857 @@
+{
+    "+1": "👍",
+    "-1": "👎",
+    "100": "💯",
+    "1234": "🔢",
+    "1st_place_medal": "🥇",
+    "2nd_place_medal": "🥈",
+    "3rd_place_medal": "🥉",
+    "8ball": "🎱",
+    "a": "🅰️",
+    "ab": "🆎",
+    "abacus": "🧮",
+    "abc": "🔤",
+    "abcd": "🔡",
+    "accept": "🉑",
+    "accordion": "🪗",
+    "adhesive_bandage": "🩹",
+    "adult": "🧑",
+    "aerial_tramway": "🚡",
+    "afghanistan": "🇦🇫",
+    "airplane": "✈️",
+    "aland_islands": "🇦🇽",
+    "alarm_clock": "⏰",
+    "albania": "🇦🇱",
+    "alembic": "⚗️",
+    "algeria": "🇩🇿",
+    "alien": "👽",
+    "ambulance": "🚑",
+    "american_samoa": "🇦🇸",
+    "amphora": "🏺",
+    "anatomical_heart": "🫀",
+    "anchor": "⚓",
+    "andorra": "🇦🇩",
+    "angel": "👼",
+    "anger": "💢",
+    "angola": "🇦🇴",
+    "angry": "😠",
+    "anguilla": "🇦🇮",
+    "anguished": "😧",
+    "ant": "🐜",
+    "antarctica": "🇦🇶",
+    "antigua_barbuda": "🇦🇬",
+    "apple": "🍎",
+    "aquarius": "♒",
+    "argentina": "🇦🇷",
+    "aries": "♈",
+    "armenia": "🇦🇲",
+    "arrow_backward": "◀️",
+    "arrow_double_down": "⏬",
+    "arrow_double_up": "⏫",
+    "arrow_down": "⬇️",
+    "arrow_down_small": "🔽",
+    "arrow_forward": "▶️",
+    "arrow_heading_down": "⤵️",
+    "arrow_heading_up": "⤴️",
+    "arrow_left": "⬅️",
+    "arrow_lower_left": "↙️",
+    "arrow_lower_right": "↘️",
+    "arrow_right": "➡️",
+    "arrow_right_hook": "↪️",
+    "arrow_up": "⬆️",
+    "arrow_up_down": "↕️",
+    "arrow_up_small": "🔼",
+    "arrow_upper_left": "↖️",
+    "arrow_upper_right": "↗️",
+    "arrows_clockwise": "🔃",
+    "arrows_counterclockwise": "🔄",
+    "art": "🎨",
+    "articulated_lorry": "🚛",
+    "artificial_satellite": "🛰️",
+    "artist": "🧑‍🎨",
+    "aruba": "🇦🇼",
+    "ascension_island": "🇦🇨",
+    "asterisk": "*️⃣",
+    "astonished": "😲",
+    "astronaut": "🧑‍🚀",
+    "athletic_shoe": "👟",
+    "atm": "🏧",
+    "atom_symbol": "⚛️",
+    "australia": "🇦🇺",
+    "austria": "🇦🇹",
+    "auto_rickshaw": "🛺",
+    "avocado": "🥑",
+    "axe": "🪓",
+    "azerbaijan": "🇦🇿",
+    "b": "🅱️",
+    "baby": "👶",
+    "baby_bottle": "🍼",
+    "baby_chick": "🐤",
+    "baby_symbol": "🚼",
+    "back": "🔙",
+    "bacon": "🥓",
+    "badger": "🦡",
+    "badminton": "🏸",
+    "bagel": "🥯",
+    "baggage_claim": "🛄",
+    "baguette_bread": "🥖",
+    "bahamas": "🇧🇸",
+    "bahrain": "🇧🇭",
+    "balance_scale": "⚖️",
+    "bald_man": "👨‍🦲",
+    "bald_woman": "👩‍🦲",
+    "ballet_shoes": "🩰",
+    "balloon": "🎈",
+    "ballot_box": "🗳️",
+    "ballot_box_with_check": "☑️",
+    "bamboo": "🎍",
+    "banana": "🍌",
+    "bangbang": "‼️",
+    "bangladesh": "🇧🇩",
+    "banjo": "🪕",
+    "bank": "🏦",
+    "bar_chart": "📊",
+    "barbados": "🇧🇧",
+    "barber": "💈",
+    "baseball": "⚾",
+    "basket": "🧺",
+    "basketball": "🏀",
+    "basketball_man": "⛹️‍♂️",
+    "basketball_woman": "⛹️‍♀️",
+    "bat": "🦇",
+    "bath": "🛀",
+    "bathtub": "🛁",
+    "battery": "🔋",
+    "beach_umbrella": "🏖️",
+    "bear": "🐻",
+    "bearded_person": "🧔",
+    "beaver": "🦫",
+    "bed": "🛏️",
+    "bee": "🐝",
+    "beer": "🍺",
+    "beers": "🍻",
+    "beetle": "🪲",
+    "beginner": "🔰",
+    "belarus": "🇧🇾",
+    "belgium": "🇧🇪",
+    "belize": "🇧🇿",
+    "bell": "🔔",
+    "bell_pepper": "🫑",
+    "bellhop_bell": "🛎️",
+    "benin": "🇧🇯",
+    "bento": "🍱",
+    "bermuda": "🇧🇲",
+    "beverage_box": "🧃",
+    "bhutan": "🇧🇹",
+    "bicyclist": "🚴",
+    "bike": "🚲",
+    "biking_man": "🚴‍♂️",
+    "biking_woman": "🚴‍♀️",
+    "bikini": "👙",
+    "billed_cap": "🧢",
+    "biohazard": "☣️",
+    "bird": "🐦",
+    "birthday": "🎂",
+    "bison": "🦬",
+    "black_cat": "🐈‍⬛",
+    "black_circle": "⚫",
+    "black_flag": "🏴",
+    "black_heart": "🖤",
+    "black_joker": "🃏",
+    "black_large_square": "⬛",
+    "black_medium_small_square": "◾",
+    "black_medium_square": "◼️",
+    "black_nib": "✒️",
+    "black_small_square": "▪️",
+    "black_square_button": "🔲",
+    "blond_haired_man": "👱‍♂️",
+    "blond_haired_person": "👱",
+    "blond_haired_woman": "👱‍♀️",
+    "blonde_woman": "👱‍♀️",
+    "blossom": "🌼",
+    "blowfish": "🐡",
+    "blue_book": "📘",
+    "blue_car": "🚙",
+    "blue_heart": "💙",
+    "blue_square": "🟦",
+    "blueberries": "🫐",
+    "blush": "😊",
+    "boar": "🐗",
+    "boat": "⛵",
+    "bolivia": "🇧🇴",
+    "bomb": "💣",
+    "bone": "🦴",
+    "book": "📖",
+    "bookmark": "🔖",
+    "bookmark_tabs": "📑",
+    "books": "📚",
+    "boom": "💥",
+    "boomerang": "🪃",
+    "boot": "👢",
+    "bosnia_herzegovina": "🇧🇦",
+    "botswana": "🇧🇼",
+    "bouncing_ball_man": "⛹️‍♂️",
+    "bouncing_ball_person": "⛹️",
+    "bouncing_ball_woman": "⛹️‍♀️",
+    "bouquet": "💐",
+    "bouvet_island": "🇧🇻",
+    "bow": "🙇",
+    "bow_and_arrow": "🏹",
+    "bowing_man": "🙇‍♂️",
+    "bowing_woman": "🙇‍♀️",
+    "bowl_with_spoon": "🥣",
+    "bowling": "🎳",
+    "boxing_glove": "🥊",
+    "boy": "👦",
+    "brain": "🧠",
+    "brazil": "🇧🇷",
+    "bread": "🍞",
+    "breast_feeding": "🤱",
+    "bricks": "🧱",
+    "bride_with_veil": "👰‍♀️",
+    "bridge_at_night": "🌉",
+    "briefcase": "💼",
+    "british_indian_ocean_territory": "🇮🇴",
+    "british_virgin_islands": "🇻🇬",
+    "broccoli": "🥦",
+    "broken_heart": "💔",
+    "broom": "🧹",
+    "brown_circle": "🟤",
+    "brown_heart": "🤎",
+    "brown_square": "🟫",
+    "brunei": "🇧🇳",
+    "bubble_tea": "🧋",
+    "bucket": "🪣",
+    "bug": "🐛",
+    "building_construction": "🏗️",
+    "bulb": "💡",
+    "bulgaria": "🇧🇬",
+    "bullettrain_front": "🚅",
+    "bullettrain_side": "🚄",
+    "burkina_faso": "🇧🇫",
+    "burrito": "🌯",
+    "burundi": "🇧🇮",
+    "bus": "🚌",
+    "business_suit_levitating": "🕴️",
+    "busstop": "🚏",
+    "bust_in_silhouette": "👤",
+    "busts_in_silhouette": "👥",
+    "butter": "🧈",
+    "butterfly": "🦋",
+    "cactus": "🌵",
+    "cake": "🍰",
+    "calendar": "📆",
+    "call_me_hand": "🤙",
+    "calling": "📲",
+    "cambodia": "🇰🇭",
+    "camel": "🐫",
+    "camera": "📷",
+    "camera_flash": "📸",
+    "cameroon": "🇨🇲",
+    "camping": "🏕️",
+    "canada": "🇨🇦",
+    "canary_islands": "🇮🇨",
+    "cancer": "♋",
+    "candle": "🕯️",
+    "candy": "🍬",
+    "canned_food": "🥫",
+    "canoe": "🛶",
+    "cape_verde": "🇨🇻",
+    "capital_abcd": "🔠",
+    "capricorn": "♑",
+    "car": "🚗",
+    "card_file_box": "🗃️",
+    "card_index": "📇",
+    "card_index_dividers": "🗂️",
+    "caribbean_netherlands": "🇧🇶",
+    "carousel_horse": "🎠",
+    "carpentry_saw": "🪚",
+    "carrot": "🥕",
+    "cartwheeling": "🤸",
+    "cat": "🐱",
+    "cat2": "🐈",
+    "cayman_islands": "🇰🇾",
+    "cd": "💿",
+    "central_african_republic": "🇨🇫",
+    "ceuta_melilla": "🇪🇦",
+    "chad": "🇹🇩",
+    "chains": "⛓️",
+    "chair": "🪑",
+    "champagne": "🍾",
+    "chart": "💹",
+    "chart_with_downwards_trend": "📉",
+    "chart_with_upwards_trend": "📈",
+    "checkered_flag": "🏁",
+    "cheese": "🧀",
+    "cherries": "🍒",
+    "cherry_blossom": "🌸",
+    "chess_pawn": "♟️",
+    "chestnut": "🌰",
+    "chicken": "🐔",
+    "child": "🧒",
+    "children_crossing": "🚸",
+    "chile": "🇨🇱",
+    "chipmunk": "🐿️",
+    "chocolate_bar": "🍫",
+    "chopsticks": "🥢",
+    "christmas_island": "🇨🇽",
+    "christmas_tree": "🎄",
+    "church": "⛪",
+    "cinema": "🎦",
+    "circus_tent": "🎪",
+    "city_sunrise": "🌇",
+    "city_sunset": "🌆",
+    "cityscape": "🏙️",
+    "cl": "🆑",
+    "clamp": "🗜️",
+    "clap": "👏",
+    "clapper": "🎬",
+    "classical_building": "🏛️",
+    "climbing": "🧗",
+    "climbing_man": "🧗‍♂️",
+    "climbing_woman": "🧗‍♀️",
+    "clinking_glasses": "🥂",
+    "clipboard": "📋",
+    "clipperton_island": "🇨🇵",
+    "clock1": "🕐",
+    "clock10": "🕙",
+    "clock1030": "🕥",
+    "clock11": "🕚",
+    "clock1130": "🕦",
+    "clock12": "🕛",
+    "clock1230": "🕧",
+    "clock130": "🕜",
+    "clock2": "🕑",
+    "clock230": "🕝",
+    "clock3": "🕒",
+    "clock330": "🕞",
+    "clock4": "🕓",
+    "clock430": "🕟",
+    "clock5": "🕔",
+    "clock530": "🕠",
+    "clock6": "🕕",
+    "clock630": "🕡",
+    "clock7": "🕖",
+    "clock730": "🕢",
+    "clock8": "🕗",
+    "clock830": "🕣",
+    "clock9": "🕘",
+    "clock930": "🕤",
+    "closed_book": "📕",
+    "closed_lock_with_key": "🔐",
+    "closed_umbrella": "🌂",
+    "cloud": "☁️",
+    "cloud_with_lightning": "🌩️",
+    "cloud_with_lightning_and_rain": "⛈️",
+    "cloud_with_rain": "🌧️",
+    "cloud_with_snow": "🌨️",
+    "clown_face": "🤡",
+    "clubs": "♣️",
+    "cn": "🇨🇳",
+    "coat": "🧥",
+    "cockroach": "🪳",
+    "cocktail": "🍸",
+    "coconut": "🥥",
+    "cocos_islands": "🇨🇨",
+    "coffee": "☕",
+    "coffin": "⚰️",
+    "coin": "🪙",
+    "cold_face": "🥶",
+    "cold_sweat": "😰",
+    "collision": "💥",
+    "colombia": "🇨🇴",
+    "comet": "☄️",
+    "comoros": "🇰🇲",
+    "compass": "🧭",
+    "computer": "💻",
+    "computer_mouse": "🖱️",
+    "confetti_ball": "🎊",
+    "confounded": "😖",
+    "confused": "😕",
+    "congo_brazzaville": "🇨🇬",
+    "congo_kinshasa": "🇨🇩",
+    "congratulations": "㊗️",
+    "construction": "🚧",
+    "construction_worker": "👷",
+    "construction_worker_man": "👷‍♂️",
+    "construction_worker_woman": "👷‍♀️",
+    "control_knobs": "🎛️",
+    "convenience_store": "🏪",
+    "cook": "🧑‍🍳",
+    "cook_islands": "🇨🇰",
+    "cookie": "🍪",
+    "cool": "🆒",
+    "cop": "👮",
+    "copyright": "©️",
+    "corn": "🌽",
+    "costa_rica": "🇨🇷",
+    "cote_divoire": "🇨🇮",
+    "couch_and_lamp": "🛋️",
+    "couple": "👫",
+    "couple_with_heart": "💑",
+    "couple_with_heart_man_man": "👨‍❤️‍👨",
+    "couple_with_heart_woman_man": "👩‍❤️‍👨",
+    "couple_with_heart_woman_woman": "👩‍❤️‍👩",
+    "couplekiss": "💏",
+    "couplekiss_man_man": "👨‍❤️‍💋‍👨",
+    "couplekiss_man_woman": "👩‍❤️‍💋‍👨",
+    "couplekiss_woman_woman": "👩‍❤️‍💋‍👩",
+    "cow": "🐮",
+    "cow2": "🐄",
+    "cowboy_hat_face": "🤠",
+    "crab": "🦀",
+    "crayon": "🖍️",
+    "credit_card": "💳",
+    "crescent_moon": "🌙",
+    "cricket": "🦗",
+    "cricket_game": "🏏",
+    "croatia": "🇭🇷",
+    "crocodile": "🐊",
+    "croissant": "🥐",
+    "crossed_fingers": "🤞",
+    "crossed_flags": "🎌",
+    "crossed_swords": "⚔️",
+    "crown": "👑",
+    "cry": "😢",
+    "crying_cat_face": "😿",
+    "crystal_ball": "🔮",
+    "cuba": "🇨🇺",
+    "cucumber": "🥒",
+    "cup_with_straw": "🥤",
+    "cupcake": "🧁",
+    "cupid": "💘",
+    "curacao": "🇨🇼",
+    "curling_stone": "🥌",
+    "curly_haired_man": "👨‍🦱",
+    "curly_haired_woman": "👩‍🦱",
+    "curly_loop": "➰",
+    "currency_exchange": "💱",
+    "curry": "🍛",
+    "cursing_face": "🤬",
+    "custard": "🍮",
+    "customs": "🛃",
+    "cut_of_meat": "🥩",
+    "cyclone": "🌀",
+    "cyprus": "🇨🇾",
+    "czech_republic": "🇨🇿",
+    "dagger": "🗡️",
+    "dancer": "💃",
+    "dancers": "👯",
+    "dancing_men": "👯‍♂️",
+    "dancing_women": "👯‍♀️",
+    "dango": "🍡",
+    "dark_sunglasses": "🕶️",
+    "dart": "🎯",
+    "dash": "💨",
+    "date": "📅",
+    "de": "🇩🇪",
+    "deaf_man": "🧏‍♂️",
+    "deaf_person": "🧏",
+    "deaf_woman": "🧏‍♀️",
+    "deciduous_tree": "🌳",
+    "deer": "🦌",
+    "denmark": "🇩🇰",
+    "department_store": "🏬",
+    "derelict_house": "🏚️",
+    "desert": "🏜️",
+    "desert_island": "🏝️",
+    "desktop_computer": "🖥️",
+    "detective": "🕵️",
+    "diamond_shape_with_a_dot_inside": "💠",
+    "diamonds": "♦️",
+    "diego_garcia": "🇩🇬",
+    "disappointed": "😞",
+    "disappointed_relieved": "😥",
+    "disguised_face": "🥸",
+    "diving_mask": "🤿",
+    "diya_lamp": "🪔",
+    "dizzy": "💫",
+    "dizzy_face": "😵",
+    "djibouti": "🇩🇯",
+    "dna": "🧬",
+    "do_not_litter": "🚯",
+    "dodo": "🦤",
+    "dog": "🐶",
+    "dog2": "🐕",
+    "dollar": "💵",
+    "dolls": "🎎",
+    "dolphin": "🐬",
+    "dominica": "🇩🇲",
+    "dominican_republic": "🇩🇴",
+    "door": "🚪",
+    "doughnut": "🍩",
+    "dove": "🕊️",
+    "dragon": "🐉",
+    "dragon_face": "🐲",
+    "dress": "👗",
+    "dromedary_camel": "🐪",
+    "drooling_face": "🤤",
+    "drop_of_blood": "🩸",
+    "droplet": "💧",
+    "drum": "🥁",
+    "duck": "🦆",
+    "dumpling": "🥟",
+    "dvd": "📀",
+    "e-mail": "📧",
+    "eagle": "🦅",
+    "ear": "👂",
+    "ear_of_rice": "🌾",
+    "ear_with_hearing_aid": "🦻",
+    "earth_africa": "🌍",
+    "earth_americas": "🌎",
+    "earth_asia": "🌏",
+    "ecuador": "🇪🇨",
+    "egg": "🥚",
+    "eggplant": "🍆",
+    "egypt": "🇪🇬",
+    "eight": "8️⃣",
+    "eight_pointed_black_star": "✴️",
+    "eight_spoked_asterisk": "✳️",
+    "eject_button": "⏏️",
+    "el_salvador": "🇸🇻",
+    "electric_plug": "🔌",
+    "elephant": "🐘",
+    "elevator": "🛗",
+    "elf": "🧝",
+    "elf_man": "🧝‍♂️",
+    "elf_woman": "🧝‍♀️",
+    "email": "📧",
+    "end": "🔚",
+    "england": "🏴󠁧󠁢󠁥󠁮󠁧󠁿",
+    "envelope": "✉️",
+    "envelope_with_arrow": "📩",
+    "equatorial_guinea": "🇬🇶",
+    "eritrea": "🇪🇷",
+    "es": "🇪🇸",
+    "estonia": "🇪🇪",
+    "ethiopia": "🇪🇹",
+    "eu": "🇪🇺",
+    "euro": "💶",
+    "european_castle": "🏰",
+    "european_post_office": "🏤",
+    "european_union": "🇪🇺",
+    "evergreen_tree": "🌲",
+    "exclamation": "❗",
+    "exploding_head": "🤯",
+    "expressionless": "😑",
+    "eye": "👁️",
+    "eye_speech_bubble": "👁️‍🗨️",
+    "eyeglasses": "👓",
+    "eyes": "👀",
+    "face_exhaling": "😮‍💨",
+    "face_in_clouds": "😶‍🌫️",
+    "face_with_head_bandage": "🤕",
+    "face_with_spiral_eyes": "😵‍💫",
+    "face_with_thermometer": "🤒",
+    "facepalm": "🤦",
+    "facepunch": "👊",
+    "factory": "🏭",
+    "factory_worker": "🧑‍🏭",
+    "fairy": "🧚",
+    "fairy_man": "🧚‍♂️",
+    "fairy_woman": "🧚‍♀️",
+    "falafel": "🧆",
+    "falkland_islands": "🇫🇰",
+    "fallen_leaf": "🍂",
+    "family": "👪",
+    "family_man_boy": "👨‍👦",
+    "family_man_boy_boy": "👨‍👦‍👦",
+    "family_man_girl": "👨‍👧",
+    "family_man_girl_boy": "👨‍👧‍👦",
+    "family_man_girl_girl": "👨‍👧‍👧",
+    "family_man_man_boy": "👨‍👨‍👦",
+    "family_man_man_boy_boy": "👨‍👨‍👦‍👦",
+    "family_man_man_girl": "👨‍👨‍👧",
+    "family_man_man_girl_boy": "👨‍👨‍👧‍👦",
+    "family_man_man_girl_girl": "👨‍👨‍👧‍👧",
+    "family_man_woman_boy": "👨‍👩‍👦",
+    "family_man_woman_boy_boy": "👨‍👩‍👦‍👦",
+    "family_man_woman_girl": "👨‍👩‍👧",
+    "family_man_woman_girl_boy": "👨‍👩‍👧‍👦",
+    "family_man_woman_girl_girl": "👨‍👩‍👧‍👧",
+    "family_woman_boy": "👩‍👦",
+    "family_woman_boy_boy": "👩‍👦‍👦",
+    "family_woman_girl": "👩‍👧",
+    "family_woman_girl_boy": "👩‍👧‍👦",
+    "family_woman_girl_girl": "👩‍👧‍👧",
+    "family_woman_woman_boy": "👩‍👩‍👦",
+    "family_woman_woman_boy_boy": "👩‍👩‍👦‍👦",
+    "family_woman_woman_girl": "👩‍👩‍👧",
+    "family_woman_woman_girl_boy": "👩‍👩‍👧‍👦",
+    "family_woman_woman_girl_girl": "👩‍👩‍👧‍👧",
+    "farmer": "🧑‍🌾",
+    "faroe_islands": "🇫🇴",
+    "fast_forward": "⏩",
+    "fax": "📠",
+    "fearful": "😨",
+    "feather": "🪶",
+    "feet": "🐾",
+    "female_detective": "🕵️‍♀️",
+    "female_sign": "♀️",
+    "ferris_wheel": "🎡",
+    "ferry": "⛴️",
+    "field_hockey": "🏑",
+    "fiji": "🇫🇯",
+    "file_cabinet": "🗄️",
+    "file_folder": "📁",
+    "film_projector": "📽️",
+    "film_strip": "🎞️",
+    "finland": "🇫🇮",
+    "fire": "🔥",
+    "fire_engine": "🚒",
+    "fire_extinguisher": "🧯",
+    "firecracker": "🧨",
+    "firefighter": "🧑‍🚒",
+    "fireworks": "🎆",
+    "first_quarter_moon": "🌓",
+    "first_quarter_moon_with_face": "🌛",
+    "fish": "🐟",
+    "fish_cake": "🍥",
+    "fishing_pole_and_fish": "🎣",
+    "fist": "✊",
+    "fist_left": "🤛",
+    "fist_oncoming": "👊",
+    "fist_raised": "✊",
+    "fist_right": "🤜",
+    "five": "5️⃣",
+    "flags": "🎏",
+    "flamingo": "🦩",
+    "flashlight": "🔦",
+    "flat_shoe": "🥿",
+    "flatbread": "🫓",
+    "fleur_de_lis": "⚜️",
+    "flight_arrival": "🛬",
+    "flight_departure": "🛫",
+    "flipper": "🐬",
+    "floppy_disk": "💾",
+    "flower_playing_cards": "🎴",
+    "flushed": "😳",
+    "fly": "🪰",
+    "flying_disc": "🥏",
+    "flying_saucer": "🛸",
+    "fog": "🌫️",
+    "foggy": "🌁",
+    "fondue": "🫕",
+    "foot": "🦶",
+    "football": "🏈",
+    "footprints": "👣",
+    "fork_and_knife": "🍴",
+    "fortune_cookie": "🥠",
+    "fountain": "⛲",
+    "fountain_pen": "🖋️",
+    "four": "4️⃣",
+    "four_leaf_clover": "🍀",
+    "fox_face": "🦊",
+    "fr": "🇫🇷",
+    "framed_picture": "🖼️",
+    "free": "🆓",
+    "french_guiana": "🇬🇫",
+    "french_polynesia": "🇵🇫",
+    "french_southern_territories": "🇹🇫",
+    "fried_egg": "🍳",
+    "fried_shrimp": "🍤",
+    "fries": "🍟",
+    "frog": "🐸",
+    "frowning": "😦",
+    "frowning_face": "☹️",
+    "frowning_man": "🙍‍♂️",
+    "frowning_person": "🙍",
+    "frowning_woman": "🙍‍♀️",
+    "fu": "🖕",
+    "fuelpump": "⛽",
+    "full_moon": "🌕",
+    "full_moon_with_face": "🌝",
+    "funeral_urn": "⚱️",
+    "gabon": "🇬🇦",
+    "gambia": "🇬🇲",
+    "game_die": "🎲",
+    "garlic": "🧄",
+    "gb": "🇬🇧",
+    "gear": "⚙️",
+    "gem": "💎",
+    "gemini": "♊",
+    "genie": "🧞",
+    "genie_man": "🧞‍♂️",
+    "genie_woman": "🧞‍♀️",
+    "georgia": "🇬🇪",
+    "ghana": "🇬🇭",
+    "ghost": "👻",
+    "gibraltar": "🇬🇮",
+    "gift": "🎁",
+    "gift_heart": "💝",
+    "giraffe": "🦒",
+    "girl": "👧",
+    "globe_with_meridians": "🌐",
+    "gloves": "🧤",
+    "goal_net": "🥅",
+    "goat": "🐐",
+    "goggles": "🥽",
+    "golf": "⛳",
+    "golfing": "🏌️",
+    "golfing_man": "🏌️‍♂️",
+    "golfing_woman": "🏌️‍♀️",
+    "gorilla": "🦍",
+    "grapes": "🍇",
+    "greece": "🇬🇷",
+    "green_apple": "🍏",
+    "green_book": "📗",
+    "green_circle": "🟢",
+    "green_heart": "💚",
+    "green_salad": "🥗",
+    "green_square": "🟩",
+    "greenland": "🇬🇱",
+    "grenada": "🇬🇩",
+    "grey_exclamation": "❕",
+    "grey_question": "❔",
+    "grimacing": "😬",
+    "grin": "😁",
+    "grinning": "😀",
+    "guadeloupe": "🇬🇵",
+    "guam": "🇬🇺",
+    "guard": "💂",
+    "guardsman": "💂‍♂️",
+    "guardswoman": "💂‍♀️",
+    "guatemala": "🇬🇹",
+    "guernsey": "🇬🇬",
+    "guide_dog": "🦮",
+    "guinea": "🇬🇳",
+    "guinea_bissau": "🇬🇼",
+    "guitar": "🎸",
+    "gun": "🔫",
+    "guyana": "🇬🇾",
+    "haircut": "💇",
+    "haircut_man": "💇‍♂️",
+    "haircut_woman": "💇‍♀️",
+    "haiti": "🇭🇹",
+    "hamburger": "🍔",
+    "hammer": "🔨",
+    "hammer_and_pick": "⚒️",
+    "hammer_and_wrench": "🛠️",
+    "hamster": "🐹",
+    "hand": "✋",
+    "hand_over_mouth": "🤭",
+    "handbag": "👜",
+    "handball_person": "🤾",
+    "handshake": "🤝",
+    "hankey": "💩",
+    "hash": "#️⃣",
+    "hatched_chick": "🐥",
+    "hatching_chick": "🐣",
+    "headphones": "🎧",
+    "headstone": "🪦",
+    "health_worker": "🧑‍⚕️",
+    "hear_no_evil": "🙉",
+    "heard_mcdonald_islands": "🇭🇲",
+    "heart": "❤️",
+    "heart_decoration": "💟",
+    "heart_eyes": "😍",
+    "heart_eyes_cat": "😻",
+    "heart_on_fire": "❤️‍🔥",
+    "heartbeat": "💓",
+    "heartpulse": "💗",
+    "hearts": "♥️",
+    "heavy_check_mark": "✔️",
+    "heavy_division_sign": "➗",
+    "heavy_dollar_sign": "💲",
+    "heavy_exclamation_mark": "❗",
+    "heavy_heart_exclamation": "❣️",
+    "heavy_minus_sign": "➖",
+    "heavy_multiplication_x": "✖️",
+    "heavy_plus_sign": "➕",
+    "hedgehog": "🦔",
+    "helicopter": "🚁",
+    "herb": "🌿",
+    "hibiscus": "🌺",
+    "high_brightness": "🔆",
+    "high_heel": "👠",
+    "hiking_boot": "🥾",
+    "hindu_temple": "🛕",
+    "hippopotamus": "🦛",
+    "hocho": "🔪",
+    "hole": "🕳️",
+    "honduras": "🇭🇳",
+    "honey_pot": "🍯",
+    "honeybee": "🐝",
+    "hong_kong": "🇭🇰",
+    "hook": "🪝",
+    "horse": "🐴",
+    "horse_racing": "🏇",
+    "hospital": "🏥",
+    "hot_face": "🥵",
+    "hot_pepper": "🌶️",
+    "hotdog": "🌭",
+    "hotel": "🏨",
+    "hotsprings": "♨️",
+    "hourglass": "⌛",
+    "hourglass_flowing_sand": "⏳",
+    "house": "🏠",
+    "house_with_garden": "🏡",
+    "houses": "🏘️",
+    "hugs": "🤗",
+    "hungary": "🇭🇺",
+    "hushed": "😯",
+    "hut": "🛖",
+    "ice_cream": "🍨",
+    "ice_cube": "🧊",
+    "ice_hockey": "🏒",
+    "ice_skate": "⛸️",
+    "icecream": "🍦",
+    "iceland": "🇮🇸",
+    "id": "🆔",
+    "ideograph_advantage": "🉐",
+    "imp": "👿",
+    "inbox_tray": "📥",
+    "incoming_envelope": "📨",
+    "india": "🇮🇳",
+    "indonesia": "🇮🇩",
+    "infinity": "♾️",
+    "information_desk_person": "💁",
+    "information_source": "ℹ️",
+    "innocent": "😇",
+    "interrobang": "⁉️",
+    "iphone": "📱",
+    "iran": "🇮🇷",
+    "iraq": "🇮🇶",
+    "ireland": "🇮🇪",
+    "isle_of_man": "🇮🇲",
+    "israel": "🇮🇱",
+    "it": "🇮🇹",
+    "izakaya_lantern": "🏮",
+    "jack_o_lantern": "🎃",
+    "jamaica": "🇯🇲",
+    "japan": "🗾",
+    "japanese_castle": "🏯",
+    "japanese_goblin": "👺",
+    "japanese_ogre": "👹",
+    "jeans": "👖",
+    "jersey": "🇯🇪",
+    "jigsaw": "🧩",
+    "jordan": "🇯🇴",
+    "joy": "😂",
+    "joy_cat": "😹",
+    "joystick": "🕹️",
+    "jp": "🇯🇵",
+    "judge": "🧑‍⚖️",
+    "juggling_person": "🤹",
+    "kaaba": "🕋",
+    "kangaroo": "🦘",
+    "kazakhstan": "🇰🇿",
+    "kenya": "🇰🇪",
+    "key": "🔑",
+    "keyboard": "⌨️",
+    "keycap_ten": "🔟",
+    "kick_scooter": "🛴",
+    "kimono": "👘",
+    "kiribati": "🇰🇮",
+    "kiss": "💋",
+    "kissing": "😗",
+    "kissing_cat": "😽",
+    "kissing_closed_eyes": "😚",
+    "kissing_heart": "😘",
+    "kissing_smiling_eyes": "😙",
+    "kite": "🪁",
+    "kiwi_fruit": "🥝",
+    "kneeling_man": "🧎‍♂️",
+    "kneeling_person": "🧎",
+    "kneeling_woman": "🧎‍♀️",
+    "knife": "🔪",
+    "knot": "🪢",
+    "koala": "🐨",
+    "koko": "🈁",
+    "kosovo": "🇽🇰",
+    "kr": "🇰🇷",
+    "kuwait": "🇰🇼",
+    "kyrgyzstan": "🇰🇬",
+    "lab_coat": "🥼",
+    "label": "🏷️",
+    "lacrosse": "🥍",
+    "ladder": "🪜",
+    "lady_beetle": "🐞",
+    "lantern": "🏮",
+    "laos": "🇱🇦",
+    "large_blue_circle": "🔵",
+    "large_blue_diamond": "🔷",
+    "large_orange_diamond": "🔶",
+    "last_quarter_moon": "🌗",
+    "last_quarter_moon_with_face": "🌜",
+    "latin_cross": "✝️",
+    "latvia": "🇱🇻",
+    "laughing": "😆",
+    "leafy_green": "🥬",
+    "leaves": "🍃",
+    "lebanon": "🇱🇧",
+    "ledger": "📒",
+    "left_luggage": "🛅",
+    "left_right_arrow": "↔️",
+    "left_speech_bubble": "🗨️",
+    "leftwards_arrow_with_hook": "↩️",
+    "leg": "🦵",
+    "lemon": "🍋",
+    "leo": "♌",
+    "leopard": "🐆",
+    "lesotho": "🇱🇸",
+    "level_slider": "🎚️",
+    "liberia": "🇱🇷",
+    "libra": "♎",
+    "libya": "🇱🇾",
+    "liechtenstein": "🇱🇮",
+    "light_rail": "🚈",
+    "link": "🔗",
+    "lion": "🦁",
+    "lips": "👄",
+    "lipstick": "💄",
+    "lithuania": "🇱🇹",
+    "lizard": "🦎",
+    "llama": "🦙",
+    "lobster": "🦞",
+    "lock": "🔒",
+    "lock_with_ink_pen": "🔏",
+    "lollipop": "🍭",
+    "long_drum": "🪘",
+    "loop": "➿",
+    "lotion_bottle": "🧴",
+    "lotus_position": "🧘",
+    "lotus_position_man": "🧘‍♂️",
+    "lotus_position_woman": "🧘‍♀️",
+    "loud_sound": "🔊",
+    "loudspeaker": "📢",
+    "love_hotel": "🏩",
+    "love_letter": "💌",
+    "love_you_gesture": "🤟",
+    "low_brightness": "🔅",
+    "luggage": "🧳",
+    "lungs": "🫁",
+    "luxembourg": "🇱🇺",
+    "lying_face": "🤥",
+    "m": "Ⓜ️",
+    "macau": "🇲🇴",
+    "macedonia": "🇲🇰",
+    "madagascar": "🇲🇬",
+    "mag": "🔍",
+    "mag_right": "🔎",
+    "mage": "🧙",
+    "mage_man": "🧙‍♂️",
+    "mage_woman": "🧙‍♀️",
+    "magic_wand": "🪄",
+    "magnet": "🧲",
+    "mahjong": "🀄",
+    "mailbox": "📫",
+    "mailbox_closed": "📪",
+    "mailbox_with_mail": "📬",
+    "mailbox_with_no_mail": "📭",
+    "malawi": "🇲🇼",
+    "malaysia": "🇲🇾",
+    "maldives": "🇲🇻",
+    "male_detective": "🕵️‍♂️",
+    "male_sign": "♂️",
+    "mali": "🇲🇱",
+    "malta": "🇲🇹",
+    "mammoth": "🦣",
+    "man": "👨",
+    "man_artist": "👨‍🎨",
+    "man_astronaut": "👨‍🚀",
+    "man_beard": "🧔‍♂️",
+    "man_cartwheeling": "🤸‍♂️",
+    "man_cook": "👨‍🍳",
+    "man_dancing": "🕺",
+    "man_facepalming": "🤦‍♂️",
+    "man_factory_worker": "👨‍🏭",
+    "man_farmer": "👨‍🌾",
+    "man_feeding_baby": "👨‍🍼",
+    "man_firefighter": "👨‍🚒",
+    "man_health_worker": "👨‍⚕️",
+    "man_in_manual_wheelchair": "👨‍🦽",
+    "man_in_motorized_wheelchair": "👨‍🦼",
+    "man_in_tuxedo": "🤵‍♂️",
+    "man_judge": "👨‍⚖️",
+    "man_juggling": "🤹‍♂️",
+    "man_mechanic": "👨‍🔧",
+    "man_office_worker": "👨‍💼",
+    "man_pilot": "👨‍✈️",
+    "man_playing_handball": "🤾‍♂️",
+    "man_playing_water_polo": "🤽‍♂️",
+    "man_scientist": "👨‍🔬",
+    "man_shrugging": "🤷‍♂️",
+    "man_singer": "👨‍🎤",
+    "man_student": "👨‍🎓",
+    "man_teacher": "👨‍🏫",
+    "man_technologist": "👨‍💻",
+    "man_with_gua_pi_mao": "👲",
+    "man_with_probing_cane": "👨‍🦯",
+    "man_with_turban": "👳‍♂️",
+    "man_with_veil": "👰‍♂️",
+    "mandarin": "🍊",
+    "mango": "🥭",
+    "mans_shoe": "👞",
+    "mantelpiece_clock": "🕰️",
+    "manual_wheelchair": "🦽",
+    "maple_leaf": "🍁",
+    "marshall_islands": "🇲🇭",
+    "martial_arts_uniform": "🥋",
+    "martinique": "🇲🇶",
+    "mask": "😷",
+    "massage": "💆",
+    "massage_man": "💆‍♂️",
+    "massage_woman": "💆‍♀️",
+    "mate": "🧉",
+    "mauritania": "🇲🇷",
+    "mauritius": "🇲🇺",
+    "mayotte": "🇾🇹",
+    "meat_on_bone": "🍖",
+    "mechanic": "🧑‍🔧",
+    "mechanical_arm": "🦾",
+    "mechanical_leg": "🦿",
+    "medal_military": "🎖️",
+    "medal_sports": "🏅",
+    "medical_symbol": "⚕️",
+    "mega": "📣",
+    "melon": "🍈",
+    "memo": "📝",
+    "men_wrestling": "🤼‍♂️",
+    "mending_heart": "❤️‍🩹",
+    "menorah": "🕎",
+    "mens": "🚹",
+    "mermaid": "🧜‍♀️",
+    "merman": "🧜‍♂️",
+    "merperson": "🧜",
+    "metal": "🤘",
+    "metro": "🚇",
+    "mexico": "🇲🇽",
+    "microbe": "🦠",
+    "micronesia": "🇫🇲",
+    "microphone": "🎤",
+    "microscope": "🔬",
+    "middle_finger": "🖕",
+    "military_helmet": "🪖",
+    "milk_glass": "🥛",
+    "milky_way": "🌌",
+    "minibus": "🚐",
+    "minidisc": "💽",
+    "mirror": "🪞",
+    "mobile_phone_off": "📴",
+    "moldova": "🇲🇩",
+    "monaco": "🇲🇨",
+    "money_mouth_face": "🤑",
+    "money_with_wings": "💸",
+    "moneybag": "💰",
+    "mongolia": "🇲🇳",
+    "monkey": "🐒",
+    "monkey_face": "🐵",
+    "monocle_face": "🧐",
+    "monorail": "🚝",
+    "montenegro": "🇲🇪",
+    "montserrat": "🇲🇸",
+    "moon": "🌔",
+    "moon_cake": "🥮",
+    "morocco": "🇲🇦",
+    "mortar_board": "🎓",
+    "mosque": "🕌",
+    "mosquito": "🦟",
+    "motor_boat": "🛥️",
+    "motor_scooter": "🛵",
+    "motorcycle": "🏍️",
+    "motorized_wheelchair": "🦼",
+    "motorway": "🛣️",
+    "mount_fuji": "🗻",
+    "mountain": "⛰️",
+    "mountain_bicyclist": "🚵",
+    "mountain_biking_man": "🚵‍♂️",
+    "mountain_biking_woman": "🚵‍♀️",
+    "mountain_cableway": "🚠",
+    "mountain_railway": "🚞",
+    "mountain_snow": "🏔️",
+    "mouse": "🐭",
+    "mouse2": "🐁",
+    "mouse_trap": "🪤",
+    "movie_camera": "🎥",
+    "moyai": "🗿",
+    "mozambique": "🇲🇿",
+    "mrs_claus": "🤶",
+    "muscle": "💪",
+    "mushroom": "🍄",
+    "musical_keyboard": "🎹",
+    "musical_note": "🎵",
+    "musical_score": "🎼",
+    "mute": "🔇",
+    "mx_claus": "🧑‍🎄",
+    "myanmar": "🇲🇲",
+    "nail_care": "💅",
+    "name_badge": "📛",
+    "namibia": "🇳🇦",
+    "national_park": "🏞️",
+    "nauru": "🇳🇷",
+    "nauseated_face": "🤢",
+    "nazar_amulet": "🧿",
+    "necktie": "👔",
+    "negative_squared_cross_mark": "❎",
+    "nepal": "🇳🇵",
+    "nerd_face": "🤓",
+    "nesting_dolls": "🪆",
+    "netherlands": "🇳🇱",
+    "neutral_face": "😐",
+    "new": "🆕",
+    "new_caledonia": "🇳🇨",
+    "new_moon": "🌑",
+    "new_moon_with_face": "🌚",
+    "new_zealand": "🇳🇿",
+    "newspaper": "📰",
+    "newspaper_roll": "🗞️",
+    "next_track_button": "⏭️",
+    "ng": "🆖",
+    "ng_man": "🙅‍♂️",
+    "ng_woman": "🙅‍♀️",
+    "nicaragua": "🇳🇮",
+    "niger": "🇳🇪",
+    "nigeria": "🇳🇬",
+    "night_with_stars": "🌃",
+    "nine": "9️⃣",
+    "ninja": "🥷",
+    "niue": "🇳🇺",
+    "no_bell": "🔕",
+    "no_bicycles": "🚳",
+    "no_entry": "⛔",
+    "no_entry_sign": "🚫",
+    "no_good": "🙅",
+    "no_good_man": "🙅‍♂️",
+    "no_good_woman": "🙅‍♀️",
+    "no_mobile_phones": "📵",
+    "no_mouth": "😶",
+    "no_pedestrians": "🚷",
+    "no_smoking": "🚭",
+    "non-potable_water": "🚱",
+    "norfolk_island": "🇳🇫",
+    "north_korea": "🇰🇵",
+    "northern_mariana_islands": "🇲🇵",
+    "norway": "🇳🇴",
+    "nose": "👃",
+    "notebook": "📓",
+    "notebook_with_decorative_cover": "📔",
+    "notes": "🎶",
+    "nut_and_bolt": "🔩",
+    "o": "⭕",
+    "o2": "🅾️",
+    "ocean": "🌊",
+    "octopus": "🐙",
+    "oden": "🍢",
+    "office": "🏢",
+    "office_worker": "🧑‍💼",
+    "oil_drum": "🛢️",
+    "ok": "🆗",
+    "ok_hand": "👌",
+    "ok_man": "🙆‍♂️",
+    "ok_person": "🙆",
+    "ok_woman": "🙆‍♀️",
+    "old_key": "🗝️",
+    "older_adult": "🧓",
+    "older_man": "👴",
+    "older_woman": "👵",
+    "olive": "🫒",
+    "om": "🕉️",
+    "oman": "🇴🇲",
+    "on": "🔛",
+    "oncoming_automobile": "🚘",
+    "oncoming_bus": "🚍",
+    "oncoming_police_car": "🚔",
+    "oncoming_taxi": "🚖",
+    "one": "1️⃣",
+    "one_piece_swimsuit": "🩱",
+    "onion": "🧅",
+    "open_book": "📖",
+    "open_file_folder": "📂",
+    "open_hands": "👐",
+    "open_mouth": "😮",
+    "open_umbrella": "☂️",
+    "ophiuchus": "⛎",
+    "orange": "🍊",
+    "orange_book": "📙",
+    "orange_circle": "🟠",
+    "orange_heart": "🧡",
+    "orange_square": "🟧",
+    "orangutan": "🦧",
+    "orthodox_cross": "☦️",
+    "otter": "🦦",
+    "outbox_tray": "📤",
+    "owl": "🦉",
+    "ox": "🐂",
+    "oyster": "🦪",
+    "package": "📦",
+    "page_facing_up": "📄",
+    "page_with_curl": "📃",
+    "pager": "📟",
+    "paintbrush": "🖌️",
+    "pakistan": "🇵🇰",
+    "palau": "🇵🇼",
+    "palestinian_territories": "🇵🇸",
+    "palm_tree": "🌴",
+    "palms_up_together": "🤲",
+    "panama": "🇵🇦",
+    "pancakes": "🥞",
+    "panda_face": "🐼",
+    "paperclip": "📎",
+    "paperclips": "🖇️",
+    "papua_new_guinea": "🇵🇬",
+    "parachute": "🪂",
+    "paraguay": "🇵🇾",
+    "parasol_on_ground": "⛱️",
+    "parking": "🅿️",
+    "parrot": "🦜",
+    "part_alternation_mark": "〽️",
+    "partly_sunny": "⛅",
+    "partying_face": "🥳",
+    "passenger_ship": "🛳️",
+    "passport_control": "🛂",
+    "pause_button": "⏸️",
+    "paw_prints": "🐾",
+    "peace_symbol": "☮️",
+    "peach": "🍑",
+    "peacock": "🦚",
+    "peanuts": "🥜",
+    "pear": "🍐",
+    "pen": "🖊️",
+    "pencil": "📝",
+    "pencil2": "✏️",
+    "penguin": "🐧",
+    "pensive": "😔",
+    "people_holding_hands": "🧑‍🤝‍🧑",
+    "people_hugging": "🫂",
+    "performing_arts": "🎭",
+    "persevere": "😣",
+    "person_bald": "🧑‍🦲",
+    "person_curly_hair": "🧑‍🦱",
+    "person_feeding_baby": "🧑‍🍼",
+    "person_fencing": "🤺",
+    "person_in_manual_wheelchair": "🧑‍🦽",
+    "person_in_motorized_wheelchair": "🧑‍🦼",
+    "person_in_tuxedo": "🤵",
+    "person_red_hair": "🧑‍🦰",
+    "person_white_hair": "🧑‍🦳",
+    "person_with_probing_cane": "🧑‍🦯",
+    "person_with_turban": "👳",
+    "person_with_veil": "👰",
+    "peru": "🇵🇪",
+    "petri_dish": "🧫",
+    "philippines": "🇵🇭",
+    "phone": "☎️",
+    "pick": "⛏️",
+    "pickup_truck": "🛻",
+    "pie": "🥧",
+    "pig": "🐷",
+    "pig2": "🐖",
+    "pig_nose": "🐽",
+    "pill": "💊",
+    "pilot": "🧑‍✈️",
+    "pinata": "🪅",
+    "pinched_fingers": "🤌",
+    "pinching_hand": "🤏",
+    "pineapple": "🍍",
+    "ping_pong": "🏓",
+    "pirate_flag": "🏴‍☠️",
+    "pisces": "♓",
+    "pitcairn_islands": "🇵🇳",
+    "pizza": "🍕",
+    "placard": "🪧",
+    "place_of_worship": "🛐",
+    "plate_with_cutlery": "🍽️",
+    "play_or_pause_button": "⏯️",
+    "pleading_face": "🥺",
+    "plunger": "🪠",
+    "point_down": "👇",
+    "point_left": "👈",
+    "point_right": "👉",
+    "point_up": "☝️",
+    "point_up_2": "👆",
+    "poland": "🇵🇱",
+    "polar_bear": "🐻‍❄️",
+    "police_car": "🚓",
+    "police_officer": "👮",
+    "policeman": "👮‍♂️",
+    "policewoman": "👮‍♀️",
+    "poodle": "🐩",
+    "poop": "💩",
+    "popcorn": "🍿",
+    "portugal": "🇵🇹",
+    "post_office": "🏣",
+    "postal_horn": "📯",
+    "postbox": "📮",
+    "potable_water": "🚰",
+    "potato": "🥔",
+    "potted_plant": "🪴",
+    "pouch": "👝",
+    "poultry_leg": "🍗",
+    "pound": "💷",
+    "pout": "😡",
+    "pouting_cat": "😾",
+    "pouting_face": "🙎",
+    "pouting_man": "🙎‍♂️",
+    "pouting_woman": "🙎‍♀️",
+    "pray": "🙏",
+    "prayer_beads": "📿",
+    "pregnant_woman": "🤰",
+    "pretzel": "🥨",
+    "previous_track_button": "⏮️",
+    "prince": "🤴",
+    "princess": "👸",
+    "printer": "🖨️",
+    "probing_cane": "🦯",
+    "puerto_rico": "🇵🇷",
+    "punch": "👊",
+    "purple_circle": "🟣",
+    "purple_heart": "💜",
+    "purple_square": "🟪",
+    "purse": "👛",
+    "pushpin": "📌",
+    "put_litter_in_its_place": "🚮",
+    "qatar": "🇶🇦",
+    "question": "❓",
+    "rabbit": "🐰",
+    "rabbit2": "🐇",
+    "raccoon": "🦝",
+    "racehorse": "🐎",
+    "racing_car": "🏎️",
+    "radio": "📻",
+    "radio_button": "🔘",
+    "radioactive": "☢️",
+    "rage": "😡",
+    "railway_car": "🚃",
+    "railway_track": "🛤️",
+    "rainbow": "🌈",
+    "rainbow_flag": "🏳️‍🌈",
+    "raised_back_of_hand": "🤚",
+    "raised_eyebrow": "🤨",
+    "raised_hand": "✋",
+    "raised_hand_with_fingers_splayed": "🖐️",
+    "raised_hands": "🙌",
+    "raising_hand": "🙋",
+    "raising_hand_man": "🙋‍♂️",
+    "raising_hand_woman": "🙋‍♀️",
+    "ram": "🐏",
+    "ramen": "🍜",
+    "rat": "🐀",
+    "razor": "🪒",
+    "receipt": "🧾",
+    "record_button": "⏺️",
+    "recycle": "♻️",
+    "red_car": "🚗",
+    "red_circle": "🔴",
+    "red_envelope": "🧧",
+    "red_haired_man": "👨‍🦰",
+    "red_haired_woman": "👩‍🦰",
+    "red_square": "🟥",
+    "registered": "®️",
+    "relaxed": "☺️",
+    "relieved": "😌",
+    "reminder_ribbon": "🎗️",
+    "repeat": "🔁",
+    "repeat_one": "🔂",
+    "rescue_worker_helmet": "⛑️",
+    "restroom": "🚻",
+    "reunion": "🇷🇪",
+    "revolving_hearts": "💞",
+    "rewind": "⏪",
+    "rhinoceros": "🦏",
+    "ribbon": "🎀",
+    "rice": "🍚",
+    "rice_ball": "🍙",
+    "rice_cracker": "🍘",
+    "rice_scene": "🎑",
+    "right_anger_bubble": "🗯️",
+    "ring": "💍",
+    "ringed_planet": "🪐",
+    "robot": "🤖",
+    "rock": "🪨",
+    "rocket": "🚀",
+    "rofl": "🤣",
+    "roll_eyes": "🙄",
+    "roll_of_paper": "🧻",
+    "roller_coaster": "🎢",
+    "roller_skate": "🛼",
+    "romania": "🇷🇴",
+    "rooster": "🐓",
+    "rose": "🌹",
+    "rosette": "🏵️",
+    "rotating_light": "🚨",
+    "round_pushpin": "📍",
+    "rowboat": "🚣",
+    "rowing_man": "🚣‍♂️",
+    "rowing_woman": "🚣‍♀️",
+    "ru": "🇷🇺",
+    "rugby_football": "🏉",
+    "runner": "🏃",
+    "running": "🏃",
+    "running_man": "🏃‍♂️",
+    "running_shirt_with_sash": "🎽",
+    "running_woman": "🏃‍♀️",
+    "rwanda": "🇷🇼",
+    "sa": "🈂️",
+    "safety_pin": "🧷",
+    "safety_vest": "🦺",
+    "sagittarius": "♐",
+    "sailboat": "⛵",
+    "sake": "🍶",
+    "salt": "🧂",
+    "samoa": "🇼🇸",
+    "san_marino": "🇸🇲",
+    "sandal": "👡",
+    "sandwich": "🥪",
+    "santa": "🎅",
+    "sao_tome_principe": "🇸🇹",
+    "sari": "🥻",
+    "sassy_man": "💁‍♂️",
+    "sassy_woman": "💁‍♀️",
+    "satellite": "📡",
+    "satisfied": "😆",
+    "saudi_arabia": "🇸🇦",
+    "sauna_man": "🧖‍♂️",
+    "sauna_person": "🧖",
+    "sauna_woman": "🧖‍♀️",
+    "sauropod": "🦕",
+    "saxophone": "🎷",
+    "scarf": "🧣",
+    "school": "🏫",
+    "school_satchel": "🎒",
+    "scientist": "🧑‍🔬",
+    "scissors": "✂️",
+    "scorpion": "🦂",
+    "scorpius": "♏",
+    "scotland": "🏴󠁧󠁢󠁳󠁣󠁴󠁿",
+    "scream": "😱",
+    "scream_cat": "🙀",
+    "screwdriver": "🪛",
+    "scroll": "📜",
+    "seal": "🦭",
+    "seat": "💺",
+    "secret": "㊙️",
+    "see_no_evil": "🙈",
+    "seedling": "🌱",
+    "selfie": "🤳",
+    "senegal": "🇸🇳",
+    "serbia": "🇷🇸",
+    "service_dog": "🐕‍🦺",
+    "seven": "7️⃣",
+    "sewing_needle": "🪡",
+    "seychelles": "🇸🇨",
+    "shallow_pan_of_food": "🥘",
+    "shamrock": "☘️",
+    "shark": "🦈",
+    "shaved_ice": "🍧",
+    "sheep": "🐑",
+    "shell": "🐚",
+    "shield": "🛡️",
+    "shinto_shrine": "⛩️",
+    "ship": "🚢",
+    "shirt": "👕",
+    "shit": "💩",
+    "shoe": "👞",
+    "shopping": "🛍️",
+    "shopping_cart": "🛒",
+    "shorts": "🩳",
+    "shower": "🚿",
+    "shrimp": "🦐",
+    "shrug": "🤷",
+    "shushing_face": "🤫",
+    "sierra_leone": "🇸🇱",
+    "signal_strength": "📶",
+    "singapore": "🇸🇬",
+    "singer": "🧑‍🎤",
+    "sint_maarten": "🇸🇽",
+    "six": "6️⃣",
+    "six_pointed_star": "🔯",
+    "skateboard": "🛹",
+    "ski": "🎿",
+    "skier": "⛷️",
+    "skull": "💀",
+    "skull_and_crossbones": "☠️",
+    "skunk": "🦨",
+    "sled": "🛷",
+    "sleeping": "😴",
+    "sleeping_bed": "🛌",
+    "sleepy": "😪",
+    "slightly_frowning_face": "🙁",
+    "slightly_smiling_face": "🙂",
+    "slot_machine": "🎰",
+    "sloth": "🦥",
+    "slovakia": "🇸🇰",
+    "slovenia": "🇸🇮",
+    "small_airplane": "🛩️",
+    "small_blue_diamond": "🔹",
+    "small_orange_diamond": "🔸",
+    "small_red_triangle": "🔺",
+    "small_red_triangle_down": "🔻",
+    "smile": "😄",
+    "smile_cat": "😸",
+    "smiley": "😃",
+    "smiley_cat": "😺",
+    "smiling_face_with_tear": "🥲",
+    "smiling_face_with_three_hearts": "🥰",
+    "smiling_imp": "😈",
+    "smirk": "😏",
+    "smirk_cat": "😼",
+    "smoking": "🚬",
+    "snail": "🐌",
+    "snake": "🐍",
+    "sneezing_face": "🤧",
+    "snowboarder": "🏂",
+    "snowflake": "❄️",
+    "snowman": "⛄",
+    "snowman_with_snow": "☃️",
+    "soap": "🧼",
+    "sob": "😭",
+    "soccer": "⚽",
+    "socks": "🧦",
+    "softball": "🥎",
+    "solomon_islands": "🇸🇧",
+    "somalia": "🇸🇴",
+    "soon": "🔜",
+    "sos": "🆘",
+    "sound": "🔉",
+    "south_africa": "🇿🇦",
+    "south_georgia_south_sandwich_islands": "🇬🇸",
+    "south_sudan": "🇸🇸",
+    "space_invader": "👾",
+    "spades": "♠️",
+    "spaghetti": "🍝",
+    "sparkle": "❇️",
+    "sparkler": "🎇",
+    "sparkles": "✨",
+    "sparkling_heart": "💖",
+    "speak_no_evil": "🙊",
+    "speaker": "🔈",
+    "speaking_head": "🗣️",
+    "speech_balloon": "💬",
+    "speedboat": "🚤",
+    "spider": "🕷️",
+    "spider_web": "🕸️",
+    "spiral_calendar": "🗓️",
+    "spiral_notepad": "🗒️",
+    "sponge": "🧽",
+    "spoon": "🥄",
+    "squid": "🦑",
+    "sri_lanka": "🇱🇰",
+    "st_barthelemy": "🇧🇱",
+    "st_helena": "🇸🇭",
+    "st_kitts_nevis": "🇰🇳",
+    "st_lucia": "🇱🇨",
+    "st_martin": "🇲🇫",
+    "st_pierre_miquelon": "🇵🇲",
+    "st_vincent_grenadines": "🇻🇨",
+    "stadium": "🏟️",
+    "standing_man": "🧍‍♂️",
+    "standing_person": "🧍",
+    "standing_woman": "🧍‍♀️",
+    "star": "⭐",
+    "star2": "🌟",
+    "star_and_crescent": "☪️",
+    "star_of_david": "✡️",
+    "star_struck": "🤩",
+    "stars": "🌠",
+    "station": "🚉",
+    "statue_of_liberty": "🗽",
+    "steam_locomotive": "🚂",
+    "stethoscope": "🩺",
+    "stew": "🍲",
+    "stop_button": "⏹️",
+    "stop_sign": "🛑",
+    "stopwatch": "⏱️",
+    "straight_ruler": "📏",
+    "strawberry": "🍓",
+    "stuck_out_tongue": "😛",
+    "stuck_out_tongue_closed_eyes": "😝",
+    "stuck_out_tongue_winking_eye": "😜",
+    "student": "🧑‍🎓",
+    "studio_microphone": "🎙️",
+    "stuffed_flatbread": "🥙",
+    "sudan": "🇸🇩",
+    "sun_behind_large_cloud": "🌥️",
+    "sun_behind_rain_cloud": "🌦️",
+    "sun_behind_small_cloud": "🌤️",
+    "sun_with_face": "🌞",
+    "sunflower": "🌻",
+    "sunglasses": "😎",
+    "sunny": "☀️",
+    "sunrise": "🌅",
+    "sunrise_over_mountains": "🌄",
+    "superhero": "🦸",
+    "superhero_man": "🦸‍♂️",
+    "superhero_woman": "🦸‍♀️",
+    "supervillain": "🦹",
+    "supervillain_man": "🦹‍♂️",
+    "supervillain_woman": "🦹‍♀️",
+    "surfer": "🏄",
+    "surfing_man": "🏄‍♂️",
+    "surfing_woman": "🏄‍♀️",
+    "suriname": "🇸🇷",
+    "sushi": "🍣",
+    "suspension_railway": "🚟",
+    "svalbard_jan_mayen": "🇸🇯",
+    "swan": "🦢",
+    "swaziland": "🇸🇿",
+    "sweat": "😓",
+    "sweat_drops": "💦",
+    "sweat_smile": "😅",
+    "sweden": "🇸🇪",
+    "sweet_potato": "🍠",
+    "swim_brief": "🩲",
+    "swimmer": "🏊",
+    "swimming_man": "🏊‍♂️",
+    "swimming_woman": "🏊‍♀️",
+    "switzerland": "🇨🇭",
+    "symbols": "🔣",
+    "synagogue": "🕍",
+    "syria": "🇸🇾",
+    "syringe": "💉",
+    "t-rex": "🦖",
+    "taco": "🌮",
+    "tada": "🎉",
+    "taiwan": "🇹🇼",
+    "tajikistan": "🇹🇯",
+    "takeout_box": "🥡",
+    "tamale": "🫔",
+    "tanabata_tree": "🎋",
+    "tangerine": "🍊",
+    "tanzania": "🇹🇿",
+    "taurus": "♉",
+    "taxi": "🚕",
+    "tea": "🍵",
+    "teacher": "🧑‍🏫",
+    "teapot": "🫖",
+    "technologist": "🧑‍💻",
+    "teddy_bear": "🧸",
+    "telephone": "☎️",
+    "telephone_receiver": "📞",
+    "telescope": "🔭",
+    "tennis": "🎾",
+    "tent": "⛺",
+    "test_tube": "🧪",
+    "thailand": "🇹🇭",
+    "thermometer": "🌡️",
+    "thinking": "🤔",
+    "thong_sandal": "🩴",
+    "thought_balloon": "💭",
+    "thread": "🧵",
+    "three": "3️⃣",
+    "thumbsdown": "👎",
+    "thumbsup": "👍",
+    "ticket": "🎫",
+    "tickets": "🎟️",
+    "tiger": "🐯",
+    "tiger2": "🐅",
+    "timer_clock": "⏲️",
+    "timor_leste": "🇹🇱",
+    "tipping_hand_man": "💁‍♂️",
+    "tipping_hand_person": "💁",
+    "tipping_hand_woman": "💁‍♀️",
+    "tired_face": "😫",
+    "tm": "™️",
+    "togo": "🇹🇬",
+    "toilet": "🚽",
+    "tokelau": "🇹🇰",
+    "tokyo_tower": "🗼",
+    "tomato": "🍅",
+    "tonga": "🇹🇴",
+    "tongue": "👅",
+    "toolbox": "🧰",
+    "tooth": "🦷",
+    "toothbrush": "🪥",
+    "top": "🔝",
+    "tophat": "🎩",
+    "tornado": "🌪️",
+    "tr": "🇹🇷",
+    "trackball": "🖲️",
+    "tractor": "🚜",
+    "traffic_light": "🚥",
+    "train": "🚋",
+    "train2": "🚆",
+    "tram": "🚊",
+    "transgender_flag": "🏳️‍⚧️",
+    "transgender_symbol": "⚧️",
+    "triangular_flag_on_post": "🚩",
+    "triangular_ruler": "📐",
+    "trident": "🔱",
+    "trinidad_tobago": "🇹🇹",
+    "tristan_da_cunha": "🇹🇦",
+    "triumph": "😤",
+    "trolleybus": "🚎",
+    "trophy": "🏆",
+    "tropical_drink": "🍹",
+    "tropical_fish": "🐠",
+    "truck": "🚚",
+    "trumpet": "🎺",
+    "tshirt": "👕",
+    "tulip": "🌷",
+    "tumbler_glass": "🥃",
+    "tunisia": "🇹🇳",
+    "turkey": "🦃",
+    "turkmenistan": "🇹🇲",
+    "turks_caicos_islands": "🇹🇨",
+    "turtle": "🐢",
+    "tuvalu": "🇹🇻",
+    "tv": "📺",
+    "twisted_rightwards_arrows": "🔀",
+    "two": "2️⃣",
+    "two_hearts": "💕",
+    "two_men_holding_hands": "👬",
+    "two_women_holding_hands": "👭",
+    "u5272": "🈹",
+    "u5408": "🈴",
+    "u55b6": "🈺",
+    "u6307": "🈯",
+    "u6708": "🈷️",
+    "u6709": "🈶",
+    "u6e80": "🈵",
+    "u7121": "🈚",
+    "u7533": "🈸",
+    "u7981": "🈲",
+    "u7a7a": "🈳",
+    "uganda": "🇺🇬",
+    "uk": "🇬🇧",
+    "ukraine": "🇺🇦",
+    "umbrella": "☔",
+    "unamused": "😒",
+    "underage": "🔞",
+    "unicorn": "🦄",
+    "united_arab_emirates": "🇦🇪",
+    "united_nations": "🇺🇳",
+    "unlock": "🔓",
+    "up": "🆙",
+    "upside_down_face": "🙃",
+    "uruguay": "🇺🇾",
+    "us": "🇺🇸",
+    "us_outlying_islands": "🇺🇲",
+    "us_virgin_islands": "🇻🇮",
+    "uzbekistan": "🇺🇿",
+    "v": "✌️",
+    "vampire": "🧛",
+    "vampire_man": "🧛‍♂️",
+    "vampire_woman": "🧛‍♀️",
+    "vanuatu": "🇻🇺",
+    "vatican_city": "🇻🇦",
+    "venezuela": "🇻🇪",
+    "vertical_traffic_light": "🚦",
+    "vhs": "📼",
+    "vibration_mode": "📳",
+    "video_camera": "📹",
+    "video_game": "🎮",
+    "vietnam": "🇻🇳",
+    "violin": "🎻",
+    "virgo": "♍",
+    "volcano": "🌋",
+    "volleyball": "🏐",
+    "vomiting_face": "🤮",
+    "vs": "🆚",
+    "vulcan_salute": "🖖",
+    "waffle": "🧇",
+    "wales": "🏴󠁧󠁢󠁷󠁬󠁳󠁿",
+    "walking": "🚶",
+    "walking_man": "🚶‍♂️",
+    "walking_woman": "🚶‍♀️",
+    "wallis_futuna": "🇼🇫",
+    "waning_crescent_moon": "🌘",
+    "waning_gibbous_moon": "🌖",
+    "warning": "⚠️",
+    "wastebasket": "🗑️",
+    "watch": "⌚",
+    "water_buffalo": "🐃",
+    "water_polo": "🤽",
+    "watermelon": "🍉",
+    "wave": "👋",
+    "wavy_dash": "〰️",
+    "waxing_crescent_moon": "🌒",
+    "waxing_gibbous_moon": "🌔",
+    "wc": "🚾",
+    "weary": "😩",
+    "wedding": "💒",
+    "weight_lifting": "🏋️",
+    "weight_lifting_man": "🏋️‍♂️",
+    "weight_lifting_woman": "🏋️‍♀️",
+    "western_sahara": "🇪🇭",
+    "whale": "🐳",
+    "whale2": "🐋",
+    "wheel_of_dharma": "☸️",
+    "wheelchair": "♿",
+    "white_check_mark": "✅",
+    "white_circle": "⚪",
+    "white_flag": "🏳️",
+    "white_flower": "💮",
+    "white_haired_man": "👨‍🦳",
+    "white_haired_woman": "👩‍🦳",
+    "white_heart": "🤍",
+    "white_large_square": "⬜",
+    "white_medium_small_square": "◽",
+    "white_medium_square": "◻️",
+    "white_small_square": "▫️",
+    "white_square_button": "🔳",
+    "wilted_flower": "🥀",
+    "wind_chime": "🎐",
+    "wind_face": "🌬️",
+    "window": "🪟",
+    "wine_glass": "🍷",
+    "wink": "😉",
+    "wolf": "🐺",
+    "woman": "👩",
+    "woman_artist": "👩‍🎨",
+    "woman_astronaut": "👩‍🚀",
+    "woman_beard": "🧔‍♀️",
+    "woman_cartwheeling": "🤸‍♀️",
+    "woman_cook": "👩‍🍳",
+    "woman_dancing": "💃",
+    "woman_facepalming": "🤦‍♀️",
+    "woman_factory_worker": "👩‍🏭",
+    "woman_farmer": "👩‍🌾",
+    "woman_feeding_baby": "👩‍🍼",
+    "woman_firefighter": "👩‍🚒",
+    "woman_health_worker": "👩‍⚕️",
+    "woman_in_manual_wheelchair": "👩‍🦽",
+    "woman_in_motorized_wheelchair": "👩‍🦼",
+    "woman_in_tuxedo": "🤵‍♀️",
+    "woman_judge": "👩‍⚖️",
+    "woman_juggling": "🤹‍♀️",
+    "woman_mechanic": "👩‍🔧",
+    "woman_office_worker": "👩‍💼",
+    "woman_pilot": "👩‍✈️",
+    "woman_playing_handball": "🤾‍♀️",
+    "woman_playing_water_polo": "🤽‍♀️",
+    "woman_scientist": "👩‍🔬",
+    "woman_shrugging": "🤷‍♀️",
+    "woman_singer": "👩‍🎤",
+    "woman_student": "👩‍🎓",
+    "woman_teacher": "👩‍🏫",
+    "woman_technologist": "👩‍💻",
+    "woman_with_headscarf": "🧕",
+    "woman_with_probing_cane": "👩‍🦯",
+    "woman_with_turban": "👳‍♀️",
+    "woman_with_veil": "👰‍♀️",
+    "womans_clothes": "👚",
+    "womans_hat": "👒",
+    "women_wrestling": "🤼‍♀️",
+    "womens": "🚺",
+    "wood": "🪵",
+    "woozy_face": "🥴",
+    "world_map": "🗺️",
+    "worm": "🪱",
+    "worried": "😟",
+    "wrench": "🔧",
+    "wrestling": "🤼",
+    "writing_hand": "✍️",
+    "x": "❌",
+    "yarn": "🧶",
+    "yawning_face": "🥱",
+    "yellow_circle": "🟡",
+    "yellow_heart": "💛",
+    "yellow_square": "🟨",
+    "yemen": "🇾🇪",
+    "yen": "💴",
+    "yin_yang": "☯️",
+    "yo_yo": "🪀",
+    "yum": "😋",
+    "zambia": "🇿🇲",
+    "zany_face": "🤪",
+    "zap": "⚡",
+    "zebra": "🦓",
+    "zero": "0️⃣",
+    "zimbabwe": "🇿🇼",
+    "zipper_mouth_face": "🤐",
+    "zombie": "🧟",
+    "zombie_man": "🧟‍♂️",
+    "zombie_woman": "🧟‍♀️",
+    "zzz": "💤"
+}

+ 37 - 1
server/server.go

@@ -82,6 +82,8 @@ var (
 	apiHealthPath                                        = "/v1/health"
 	apiStatsPath                                         = "/v1/stats"
 	apiTiersPath                                         = "/v1/tiers"
+	apiUsersPath                                         = "/v1/users"
+	apiUsersAccessPath                                   = "/v1/users/access"
 	apiAccountPath                                       = "/v1/account"
 	apiAccountTokenPath                                  = "/v1/account/token"
 	apiAccountPasswordPath                               = "/v1/account/password"
@@ -413,6 +415,16 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
 		return s.handleHealth(w, r, v)
 	} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
 		return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
+	} else if r.Method == http.MethodGet && r.URL.Path == apiUsersPath {
+		return s.ensureAdmin(s.handleUsersGet)(w, r, v)
+	} else if r.Method == http.MethodPut && r.URL.Path == apiUsersPath {
+		return s.ensureAdmin(s.handleUsersAdd)(w, r, v)
+	} else if r.Method == http.MethodDelete && r.URL.Path == apiUsersPath {
+		return s.ensureAdmin(s.handleUsersDelete)(w, r, v)
+	} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == apiUsersAccessPath {
+		return s.ensureAdmin(s.handleAccessAllow)(w, r, v)
+	} else if r.Method == http.MethodDelete && r.URL.Path == apiUsersAccessPath {
+		return s.ensureAdmin(s.handleAccessReset)(w, r, v)
 	} else if r.Method == http.MethodPost && r.URL.Path == apiAccountPath {
 		return s.ensureUserManager(s.handleAccountCreate)(w, r, v)
 	} else if r.Method == http.MethodGet && r.URL.Path == apiAccountPath {
@@ -651,6 +663,9 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor)
 		return err
 	}
 	defer f.Close()
+	if m.Attachment.Name != "" {
+		w.Header().Set("Content-Disposition", "attachment; filename="+strconv.Quote(m.Attachment.Name))
+	}
 	_, err = io.Copy(util.NewContentTypeWriter(w, r.URL.Path), f)
 	return err
 }
@@ -1221,7 +1236,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
 	}
 	defer conn.Close()
 
-	// Subscription connections can be canceled externally, see topic.CancelSubscribers
+	// Subscription connections can be canceled externally, see topic.CancelSubscribersExceptUser
 	cancelCtx, cancel := context.WithCancel(context.Background())
 	defer cancel()
 
@@ -1463,6 +1478,7 @@ func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request, _ *visito
 	return nil
 }
 
+// topicFromPath returns the topic from a root path (e.g. /mytopic), creating it if it doesn't exist.
 func (s *Server) topicFromPath(path string) (*topic, error) {
 	parts := strings.Split(path, "/")
 	if len(parts) < 2 {
@@ -1471,6 +1487,7 @@ func (s *Server) topicFromPath(path string) (*topic, error) {
 	return s.topicFromID(parts[1])
 }
 
+// topicsFromPath returns the topic from a root path (e.g. /mytopic,mytopic2), creating it if it doesn't exist.
 func (s *Server) topicsFromPath(path string) ([]*topic, string, error) {
 	parts := strings.Split(path, "/")
 	if len(parts) < 2 {
@@ -1484,6 +1501,7 @@ func (s *Server) topicsFromPath(path string) ([]*topic, string, error) {
 	return topics, parts[1], nil
 }
 
+// topicsFromIDs returns the topics with the given IDs, creating them if they don't exist.
 func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) {
 	s.mu.Lock()
 	defer s.mu.Unlock()
@@ -1503,6 +1521,7 @@ func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) {
 	return topics, nil
 }
 
+// topicFromID returns the topic with the given ID, creating it if it doesn't exist.
 func (s *Server) topicFromID(id string) (*topic, error) {
 	topics, err := s.topicsFromIDs(id)
 	if err != nil {
@@ -1511,6 +1530,23 @@ func (s *Server) topicFromID(id string) (*topic, error) {
 	return topics[0], nil
 }
 
+// topicsFromPattern returns a list of topics matching the given pattern, but it does not create them.
+func (s *Server) topicsFromPattern(pattern string) ([]*topic, error) {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+	patternRegexp, err := regexp.Compile("^" + strings.ReplaceAll(pattern, "*", ".*") + "$")
+	if err != nil {
+		return nil, err
+	}
+	topics := make([]*topic, 0)
+	for _, t := range s.topics {
+		if patternRegexp.MatchString(t.ID) {
+			topics = append(topics, t)
+		}
+	}
+	return topics, nil
+}
+
 func (s *Server) runSMTPServer() error {
 	s.smtpServerBackend = newMailBackend(s.config, s.handle)
 	s.smtpServer = smtp.NewServer(s.smtpServerBackend)

+ 1 - 1
server/server_account.go

@@ -454,7 +454,7 @@ func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Requ
 	if err != nil {
 		return err
 	}
-	t.CancelSubscribers(u.ID)
+	t.CancelSubscribersExceptUser(u.ID)
 	return s.writeJSON(w, newSuccessResponse())
 }
 

+ 143 - 0
server/server_admin.go

@@ -0,0 +1,143 @@
+package server
+
+import (
+	"heckel.io/ntfy/user"
+	"net/http"
+)
+
+func (s *Server) handleUsersGet(w http.ResponseWriter, r *http.Request, v *visitor) error {
+	users, err := s.userManager.Users()
+	if err != nil {
+		return err
+	}
+	grants, err := s.userManager.AllGrants()
+	if err != nil {
+		return err
+	}
+	usersResponse := make([]*apiUserResponse, len(users))
+	for i, u := range users {
+		tier := ""
+		if u.Tier != nil {
+			tier = u.Tier.Code
+		}
+		userGrants := make([]*apiUserGrantResponse, len(grants[u.ID]))
+		for i, g := range grants[u.ID] {
+			userGrants[i] = &apiUserGrantResponse{
+				Topic:      g.TopicPattern,
+				Permission: g.Allow.String(),
+			}
+		}
+		usersResponse[i] = &apiUserResponse{
+			Username: u.Name,
+			Role:     string(u.Role),
+			Tier:     tier,
+			Grants:   userGrants,
+		}
+	}
+	return s.writeJSON(w, usersResponse)
+}
+
+func (s *Server) handleUsersAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
+	req, err := readJSONWithLimit[apiUserAddRequest](r.Body, jsonBodyBytesLimit, false)
+	if err != nil {
+		return err
+	} else if !user.AllowedUsername(req.Username) || req.Password == "" {
+		return errHTTPBadRequest.Wrap("username invalid, or password missing")
+	}
+	u, err := s.userManager.User(req.Username)
+	if err != nil && err != user.ErrUserNotFound {
+		return err
+	} else if u != nil {
+		return errHTTPConflictUserExists
+	}
+	var tier *user.Tier
+	if req.Tier != "" {
+		tier, err = s.userManager.Tier(req.Tier)
+		if err == user.ErrTierNotFound {
+			return errHTTPBadRequestTierInvalid
+		} else if err != nil {
+			return err
+		}
+	}
+	if err := s.userManager.AddUser(req.Username, req.Password, user.RoleUser); err != nil {
+		return err
+	}
+	if tier != nil {
+		if err := s.userManager.ChangeTier(req.Username, req.Tier); err != nil {
+			return err
+		}
+	}
+	return s.writeJSON(w, newSuccessResponse())
+}
+
+func (s *Server) handleUsersDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
+	req, err := readJSONWithLimit[apiUserDeleteRequest](r.Body, jsonBodyBytesLimit, false)
+	if err != nil {
+		return err
+	}
+	u, err := s.userManager.User(req.Username)
+	if err == user.ErrUserNotFound {
+		return errHTTPBadRequestUserNotFound
+	} else if err != nil {
+		return err
+	} else if !u.IsUser() {
+		return errHTTPUnauthorized.Wrap("can only remove regular users from API")
+	}
+	if err := s.userManager.RemoveUser(req.Username); err != nil {
+		return err
+	}
+	if err := s.killUserSubscriber(u, "*"); err != nil { // FIXME super inefficient
+		return err
+	}
+	return s.writeJSON(w, newSuccessResponse())
+}
+
+func (s *Server) handleAccessAllow(w http.ResponseWriter, r *http.Request, v *visitor) error {
+	req, err := readJSONWithLimit[apiAccessAllowRequest](r.Body, jsonBodyBytesLimit, false)
+	if err != nil {
+		return err
+	}
+	_, err = s.userManager.User(req.Username)
+	if err == user.ErrUserNotFound {
+		return errHTTPBadRequestUserNotFound
+	} else if err != nil {
+		return err
+	}
+	permission, err := user.ParsePermission(req.Permission)
+	if err != nil {
+		return errHTTPBadRequestPermissionInvalid
+	}
+	if err := s.userManager.AllowAccess(req.Username, req.Topic, permission); err != nil {
+		return err
+	}
+	return s.writeJSON(w, newSuccessResponse())
+}
+
+func (s *Server) handleAccessReset(w http.ResponseWriter, r *http.Request, v *visitor) error {
+	req, err := readJSONWithLimit[apiAccessResetRequest](r.Body, jsonBodyBytesLimit, false)
+	if err != nil {
+		return err
+	}
+	u, err := s.userManager.User(req.Username)
+	if err != nil {
+		return err
+	}
+	if err := s.userManager.ResetAccess(req.Username, req.Topic); err != nil {
+		return err
+	}
+	if err := s.killUserSubscriber(u, req.Topic); err != nil { // This may be a pattern
+		return err
+	}
+	return s.writeJSON(w, newSuccessResponse())
+}
+
+func (s *Server) killUserSubscriber(u *user.User, topicPattern string) error {
+	topics, err := s.topicsFromPattern(topicPattern)
+	if err != nil {
+		return err
+	}
+	for _, t := range topics {
+		t.CancelSubscriberUser(u.ID)
+	}
+	return nil
+}

+ 181 - 0
server/server_admin_test.go

@@ -0,0 +1,181 @@
+package server
+
+import (
+	"github.com/stretchr/testify/require"
+	"heckel.io/ntfy/user"
+	"heckel.io/ntfy/util"
+	"sync/atomic"
+	"testing"
+	"time"
+)
+
+func TestUser_AddRemove(t *testing.T) {
+	s := newTestServer(t, newTestConfigWithAuthFile(t))
+	defer s.closeDatabases()
+
+	// Create admin, tier
+	require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
+	require.Nil(t, s.userManager.AddTier(&user.Tier{
+		Code: "tier1",
+	}))
+
+	// Create user via API
+	rr := request(t, s, "PUT", "/v1/users", `{"username": "ben", "password":"ben"}`, map[string]string{
+		"Authorization": util.BasicAuth("phil", "phil"),
+	})
+	require.Equal(t, 200, rr.Code)
+
+	// Create user with tier via API
+	rr = request(t, s, "PUT", "/v1/users", `{"username": "emma", "password":"emma", "tier": "tier1"}`, map[string]string{
+		"Authorization": util.BasicAuth("phil", "phil"),
+	})
+	require.Equal(t, 200, rr.Code)
+
+	// Check users
+	users, err := s.userManager.Users()
+	require.Nil(t, err)
+	require.Equal(t, 4, len(users))
+	require.Equal(t, "phil", users[0].Name)
+	require.Equal(t, "ben", users[1].Name)
+	require.Equal(t, user.RoleUser, users[1].Role)
+	require.Nil(t, users[1].Tier)
+	require.Equal(t, "emma", users[2].Name)
+	require.Equal(t, user.RoleUser, users[2].Role)
+	require.Equal(t, "tier1", users[2].Tier.Code)
+	require.Equal(t, user.Everyone, users[3].Name)
+
+	// Delete user via API
+	rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{
+		"Authorization": util.BasicAuth("phil", "phil"),
+	})
+	require.Equal(t, 200, rr.Code)
+}
+
+func TestUser_AddRemove_Failures(t *testing.T) {
+	s := newTestServer(t, newTestConfigWithAuthFile(t))
+	defer s.closeDatabases()
+
+	// Create admin
+	require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
+	require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
+
+	// Cannot create user with invalid username
+	rr := request(t, s, "PUT", "/v1/users", `{"username": "not valid", "password":"ben"}`, map[string]string{
+		"Authorization": util.BasicAuth("phil", "phil"),
+	})
+	require.Equal(t, 400, rr.Code)
+
+	// Cannot create user if user already exists
+	rr = request(t, s, "PUT", "/v1/users", `{"username": "phil", "password":"phil"}`, map[string]string{
+		"Authorization": util.BasicAuth("phil", "phil"),
+	})
+	require.Equal(t, 40901, toHTTPError(t, rr.Body.String()).Code)
+
+	// Cannot create user with invalid tier
+	rr = request(t, s, "PUT", "/v1/users", `{"username": "emma", "password":"emma", "tier": "invalid"}`, map[string]string{
+		"Authorization": util.BasicAuth("phil", "phil"),
+	})
+	require.Equal(t, 40030, toHTTPError(t, rr.Body.String()).Code)
+
+	// Cannot delete user as non-admin
+	rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{
+		"Authorization": util.BasicAuth("ben", "ben"),
+	})
+	require.Equal(t, 401, rr.Code)
+
+	// Delete user via API
+	rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{
+		"Authorization": util.BasicAuth("phil", "phil"),
+	})
+	require.Equal(t, 200, rr.Code)
+}
+
+func TestAccess_AllowReset(t *testing.T) {
+	c := newTestConfigWithAuthFile(t)
+	c.AuthDefault = user.PermissionDenyAll
+	s := newTestServer(t, c)
+	defer s.closeDatabases()
+
+	// User and admin
+	require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
+	require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
+
+	// Subscribing not allowed
+	rr := request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{
+		"Authorization": util.BasicAuth("ben", "ben"),
+	})
+	require.Equal(t, 403, rr.Code)
+
+	// Grant access
+	rr = request(t, s, "POST", "/v1/users/access", `{"username": "ben", "topic":"gold", "permission":"ro"}`, map[string]string{
+		"Authorization": util.BasicAuth("phil", "phil"),
+	})
+	require.Equal(t, 200, rr.Code)
+
+	// Now subscribing is allowed
+	rr = request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{
+		"Authorization": util.BasicAuth("ben", "ben"),
+	})
+	require.Equal(t, 200, rr.Code)
+
+	// Reset access
+	rr = request(t, s, "DELETE", "/v1/users/access", `{"username": "ben", "topic":"gold"}`, map[string]string{
+		"Authorization": util.BasicAuth("phil", "phil"),
+	})
+	require.Equal(t, 200, rr.Code)
+
+	// Subscribing not allowed (again)
+	rr = request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{
+		"Authorization": util.BasicAuth("ben", "ben"),
+	})
+	require.Equal(t, 403, rr.Code)
+}
+
+func TestAccess_AllowReset_NonAdminAttempt(t *testing.T) {
+	c := newTestConfigWithAuthFile(t)
+	c.AuthDefault = user.PermissionDenyAll
+	s := newTestServer(t, c)
+	defer s.closeDatabases()
+
+	// User
+	require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
+
+	// Grant access fails, because non-admin
+	rr := request(t, s, "POST", "/v1/users/access", `{"username": "ben", "topic":"gold", "permission":"ro"}`, map[string]string{
+		"Authorization": util.BasicAuth("ben", "ben"),
+	})
+	require.Equal(t, 401, rr.Code)
+}
+
+func TestAccess_AllowReset_KillConnection(t *testing.T) {
+	c := newTestConfigWithAuthFile(t)
+	c.AuthDefault = user.PermissionDenyAll
+	s := newTestServer(t, c)
+	defer s.closeDatabases()
+
+	// User and admin, grant access to "gol*" topics
+	require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
+	require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
+	require.Nil(t, s.userManager.AllowAccess("ben", "gol*", user.PermissionRead)) // Wildcard!
+
+	start, timeTaken := time.Now(), atomic.Int64{}
+	go func() {
+		rr := request(t, s, "GET", "/gold/json", "", map[string]string{
+			"Authorization": util.BasicAuth("ben", "ben"),
+		})
+		require.Equal(t, 200, rr.Code)
+		timeTaken.Store(time.Since(start).Milliseconds())
+	}()
+	time.Sleep(500 * time.Millisecond)
+
+	// Reset access
+	rr := request(t, s, "DELETE", "/v1/users/access", `{"username": "ben", "topic":"gol*"}`, map[string]string{
+		"Authorization": util.BasicAuth("phil", "phil"),
+	})
+	require.Equal(t, 200, rr.Code)
+
+	// Wait for connection to be killed; this will fail if the connection is never killed
+	waitFor(t, func() bool {
+		return timeTaken.Load() >= 500
+	})
+}

+ 9 - 0
server/server_middleware.go

@@ -76,6 +76,15 @@ func (s *Server) ensureUser(next handleFunc) handleFunc {
 	})
 }
 
+func (s *Server) ensureAdmin(next handleFunc) handleFunc {
+	return s.ensureUserManager(func(w http.ResponseWriter, r *http.Request, v *visitor) error {
+		if !v.User().IsAdmin() {
+			return errHTTPUnauthorized
+		}
+		return next(w, r, v)
+	})
+}
+
 func (s *Server) ensurePaymentsEnabled(next handleFunc) handleFunc {
 	return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
 		if s.config.StripeSecretKey == "" || s.stripe == nil {

+ 11 - 18
server/smtp_sender.go

@@ -4,14 +4,15 @@ import (
 	_ "embed" // required by go:embed
 	"encoding/json"
 	"fmt"
-	"heckel.io/ntfy/log"
-	"heckel.io/ntfy/util"
 	"mime"
 	"net"
 	"net/smtp"
 	"strings"
 	"sync"
 	"time"
+
+	"heckel.io/ntfy/log"
+	"heckel.io/ntfy/util"
 )
 
 type mailer interface {
@@ -131,31 +132,23 @@ This message was sent by {ip} at {time} via {topicURL}`
 }
 
 var (
-	//go:embed "mailer_emoji.json"
+	//go:embed "mailer_emoji_map.json"
 	emojisJSON string
 )
 
-type emoji struct {
-	Emoji   string   `json:"emoji"`
-	Aliases []string `json:"aliases"`
-}
-
 func toEmojis(tags []string) (emojisOut []string, tagsOut []string, err error) {
-	var emojis []emoji
-	if err = json.Unmarshal([]byte(emojisJSON), &emojis); err != nil {
+	var emojiMap map[string]string
+	if err = json.Unmarshal([]byte(emojisJSON), &emojiMap); err != nil {
 		return nil, nil, err
 	}
 	tagsOut = make([]string, 0)
 	emojisOut = make([]string, 0)
-nextTag:
-	for _, t := range tags { // TODO Super inefficient; we should just create a .json file with a map
-		for _, e := range emojis {
-			if util.Contains(e.Aliases, t) {
-				emojisOut = append(emojisOut, e.Emoji)
-				continue nextTag
-			}
+	for _, t := range tags {
+		if emoji, ok := emojiMap[t]; ok {
+			emojisOut = append(emojisOut, emoji)
+		} else {
+			tagsOut = append(tagsOut, t)
 		}
-		tagsOut = append(tagsOut, t)
 	}
 	return
 }

+ 26 - 10
server/topic.go

@@ -141,24 +141,40 @@ func (t *topic) Keepalive() {
 	t.lastAccess = time.Now()
 }
 
-// CancelSubscribers calls the cancel function for all subscribers, forcing
-func (t *topic) CancelSubscribers(exceptUserID string) {
+// CancelSubscribersExceptUser calls the cancel function for all subscribers, forcing
+func (t *topic) CancelSubscribersExceptUser(exceptUserID string) {
 	t.mu.Lock()
 	defer t.mu.Unlock()
 	for _, s := range t.subscribers {
 		if s.userID != exceptUserID {
-			log.
-				Tag(tagSubscribe).
-				With(t).
-				Fields(log.Context{
-					"user_id": s.userID,
-				}).
-				Debug("Canceling subscriber %s", s.userID)
-			s.cancel()
+			t.cancelUserSubscriber(s)
 		}
 	}
 }
 
+// CancelSubscriberUser kills the subscriber with the given user ID
+func (t *topic) CancelSubscriberUser(userID string) {
+	t.mu.RLock()
+	defer t.mu.RUnlock()
+	for _, s := range t.subscribers {
+		if s.userID == userID {
+			t.cancelUserSubscriber(s)
+			return
+		}
+	}
+}
+
+func (t *topic) cancelUserSubscriber(s *topicSubscriber) {
+	log.
+		Tag(tagSubscribe).
+		With(t).
+		Fields(log.Context{
+			"user_id": s.userID,
+		}).
+		Debug("Canceling subscriber with user ID %s", s.userID)
+	s.cancel()
+}
+
 func (t *topic) Context() log.Context {
 	t.mu.RLock()
 	defer t.mu.RUnlock()

+ 25 - 2
server/topic_test.go

@@ -9,7 +9,7 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func TestTopic_CancelSubscribers(t *testing.T) {
+func TestTopic_CancelSubscribersExceptUser(t *testing.T) {
 	t.Parallel()
 
 	subFn := func(v *visitor, msg *message) error {
@@ -27,11 +27,34 @@ func TestTopic_CancelSubscribers(t *testing.T) {
 	to.Subscribe(subFn, "", cancelFn1)
 	to.Subscribe(subFn, "u_phil", cancelFn2)
 
-	to.CancelSubscribers("u_phil")
+	to.CancelSubscribersExceptUser("u_phil")
 	require.True(t, canceled1.Load())
 	require.False(t, canceled2.Load())
 }
 
+func TestTopic_CancelSubscribersUser(t *testing.T) {
+	t.Parallel()
+
+	subFn := func(v *visitor, msg *message) error {
+		return nil
+	}
+	canceled1 := atomic.Bool{}
+	cancelFn1 := func() {
+		canceled1.Store(true)
+	}
+	canceled2 := atomic.Bool{}
+	cancelFn2 := func() {
+		canceled2.Store(true)
+	}
+	to := newTopic("mytopic")
+	to.Subscribe(subFn, "u_another", cancelFn1)
+	to.Subscribe(subFn, "u_phil", cancelFn2)
+
+	to.CancelSubscriberUser("u_phil")
+	require.False(t, canceled1.Load())
+	require.True(t, canceled2.Load())
+}
+
 func TestTopic_Keepalive(t *testing.T) {
 	t.Parallel()
 

+ 34 - 0
server/types.go

@@ -244,6 +244,40 @@ type apiStatsResponse struct {
 	MessagesRate float64 `json:"messages_rate"` // Average number of messages per second
 }
 
+type apiUserAddRequest struct {
+	Username string `json:"username"`
+	Password string `json:"password"`
+	Tier     string `json:"tier"`
+	// Do not add 'role' here. We don't want to add admins via the API.
+}
+
+type apiUserResponse struct {
+	Username string                  `json:"username"`
+	Role     string                  `json:"role"`
+	Tier     string                  `json:"tier,omitempty"`
+	Grants   []*apiUserGrantResponse `json:"grants,omitempty"`
+}
+
+type apiUserGrantResponse struct {
+	Topic      string `json:"topic"` // This may be a pattern
+	Permission string `json:"permission"`
+}
+
+type apiUserDeleteRequest struct {
+	Username string `json:"username"`
+}
+
+type apiAccessAllowRequest struct {
+	Username   string `json:"username"`
+	Topic      string `json:"topic"` // This may be a pattern
+	Permission string `json:"permission"`
+}
+
+type apiAccessResetRequest struct {
+	Username string `json:"username"`
+	Topic    string `json:"topic"`
+}
+
 type apiAccountCreateRequest struct {
 	Username string `json:"username"`
 	Password string `json:"password"`

+ 32 - 0
user/manager.go

@@ -195,6 +195,11 @@ const (
 		ON CONFLICT (user_id, topic)
 		DO UPDATE SET read=excluded.read, write=excluded.write, owner_user_id=excluded.owner_user_id
 	`
+	selectUserAllAccessQuery = `
+		SELECT user_id, topic, read, write
+		FROM user_access
+		ORDER BY write DESC, read DESC, topic
+	`
 	selectUserAccessQuery = `
 		SELECT topic, read, write
 		FROM user_access
@@ -1050,6 +1055,33 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
 	return user, nil
 }
 
+// AllGrants returns all user-specific access control entries, mapped to their respective user IDs
+func (a *Manager) AllGrants() (map[string][]Grant, error) {
+	rows, err := a.db.Query(selectUserAllAccessQuery)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	grants := make(map[string][]Grant, 0)
+	for rows.Next() {
+		var userID, topic string
+		var read, write bool
+		if err := rows.Scan(&userID, &topic, &read, &write); err != nil {
+			return nil, err
+		} else if err := rows.Err(); err != nil {
+			return nil, err
+		}
+		if _, ok := grants[userID]; !ok {
+			grants[userID] = make([]Grant, 0)
+		}
+		grants[userID] = append(grants[userID], Grant{
+			TopicPattern: fromSQLWildcard(topic),
+			Allow:        NewPermission(read, write),
+		})
+	}
+	return grants, nil
+}
+
 // Grants returns all user-specific access control entries
 func (a *Manager) Grants(username string) ([]Grant, error) {
 	rows, err := a.db.Query(selectUserAccessQuery, username)

+ 189 - 182
web/package-lock.json

@@ -3134,9 +3134,9 @@
       "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A=="
     },
     "node_modules/@mui/base": {
-      "version": "5.0.0-alpha.128",
-      "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-alpha.128.tgz",
-      "integrity": "sha512-wub3wxNN+hUp8hzilMlXX3sZrPo75vsy1cXEQpqdTfIFlE9HprP1jlulFiPg5tfPst2OKmygXr2hhmgvAKRrzQ==",
+      "version": "5.0.0-beta.0",
+      "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.0.tgz",
+      "integrity": "sha512-ap+juKvt8R8n3cBqd/pGtZydQ4v2I/hgJKnvJRGjpSh3RvsvnDHO4rXov8MHQlH6VqpOekwgilFLGxMZjNTucA==",
       "dependencies": {
         "@babel/runtime": "^7.21.0",
         "@emotion/is-prop-valid": "^1.2.0",
@@ -3166,9 +3166,9 @@
       }
     },
     "node_modules/@mui/core-downloads-tracker": {
-      "version": "5.12.3",
-      "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.12.3.tgz",
-      "integrity": "sha512-yiJZ+knaknPHuRKhRk4L6XiwppwkAahVal3LuYpvBH7GkA2g+D9WLEXOEnNYtVFUggyKf6fWGLGnx0iqzkU5YA==",
+      "version": "5.13.0",
+      "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.13.0.tgz",
+      "integrity": "sha512-5nXz2k8Rv2ZjtQY6kXirJVyn2+ODaQuAJmXSJtLDUQDKWp3PFUj6j3bILqR0JGOs9R5ejgwz3crLKsl6GwjwkQ==",
       "funding": {
         "type": "opencollective",
         "url": "https://opencollective.com/mui"
@@ -3200,17 +3200,17 @@
       }
     },
     "node_modules/@mui/material": {
-      "version": "5.12.3",
-      "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.12.3.tgz",
-      "integrity": "sha512-xNmKlrEN4HsTaKFNLZfc7ie7CXx2YqEeO//hsXZx2p3MGtDdeMr2sV3jC4hsFs57RhQlF79weY7uVvC8xSuVbg==",
+      "version": "5.13.0",
+      "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.13.0.tgz",
+      "integrity": "sha512-ckS+9tCpAzpdJdaTF+btF0b6mF9wbXg/EVKtnoAWYi0UKXoXBAVvEUMNpLGA5xdpCdf+A6fPbVUEHs9TsfU+Yw==",
       "dependencies": {
         "@babel/runtime": "^7.21.0",
-        "@mui/base": "5.0.0-alpha.128",
-        "@mui/core-downloads-tracker": "^5.12.3",
+        "@mui/base": "5.0.0-beta.0",
+        "@mui/core-downloads-tracker": "^5.13.0",
         "@mui/system": "^5.12.3",
         "@mui/types": "^7.2.4",
         "@mui/utils": "^5.12.3",
-        "@types/react-transition-group": "^4.4.5",
+        "@types/react-transition-group": "^4.4.6",
         "clsx": "^1.2.1",
         "csstype": "^3.1.2",
         "prop-types": "^15.8.1",
@@ -3948,9 +3948,9 @@
       }
     },
     "node_modules/@types/express-serve-static-core": {
-      "version": "4.17.34",
-      "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.34.tgz",
-      "integrity": "sha512-fvr49XlCGoUj2Pp730AItckfjat4WNb0lb3kfrLWffd+RLeoGAMsq7UOy04PAPtoL01uKwcp6u8nhzpgpDYr3w==",
+      "version": "4.17.35",
+      "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.35.tgz",
+      "integrity": "sha512-wALWQwrgiB2AWTT91CB62b6Yt0sNHpznUXeZEcnPU3DRdlDIz74x8Qg1UUYKSVFi+va5vKOLYRBI1bRKiLLKIg==",
       "dependencies": {
         "@types/node": "*",
         "@types/qs": "*",
@@ -4016,9 +4016,9 @@
       "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw=="
     },
     "node_modules/@types/node": {
-      "version": "20.1.0",
-      "resolved": "https://registry.npmjs.org/@types/node/-/node-20.1.0.tgz",
-      "integrity": "sha512-O+z53uwx64xY7D6roOi4+jApDGFg0qn6WHcxe5QeqjMaTezBO/mxdfFXIVAVVyNWKx84OmPB3L8kbVYOTeN34A=="
+      "version": "20.1.4",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-20.1.4.tgz",
+      "integrity": "sha512-At4pvmIOki8yuwLtd7BNHl3CiWNbtclUbNtScGx4OHfBd4/oWoJC8KRCIxXwkdndzhxOsPXihrsOoydxBjlE9Q=="
     },
     "node_modules/@types/parse-json": {
       "version": "4.0.0",
@@ -4105,9 +4105,9 @@
       "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ=="
     },
     "node_modules/@types/semver": {
-      "version": "7.3.13",
-      "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.13.tgz",
-      "integrity": "sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw=="
+      "version": "7.5.0",
+      "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.0.tgz",
+      "integrity": "sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw=="
     },
     "node_modules/@types/send": {
       "version": "0.17.1",
@@ -4175,14 +4175,14 @@
       "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA=="
     },
     "node_modules/@typescript-eslint/eslint-plugin": {
-      "version": "5.59.2",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.2.tgz",
-      "integrity": "sha512-yVrXupeHjRxLDcPKL10sGQ/QlVrA8J5IYOEWVqk0lJaSZP7X5DfnP7Ns3cc74/blmbipQ1htFNVGsHX6wsYm0A==",
+      "version": "5.59.5",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.5.tgz",
+      "integrity": "sha512-feA9xbVRWJZor+AnLNAr7A8JRWeZqHUf4T9tlP+TN04b05pFVhO5eN7/O93Y/1OUlLMHKbnJisgDURs/qvtqdg==",
       "dependencies": {
         "@eslint-community/regexpp": "^4.4.0",
-        "@typescript-eslint/scope-manager": "5.59.2",
-        "@typescript-eslint/type-utils": "5.59.2",
-        "@typescript-eslint/utils": "5.59.2",
+        "@typescript-eslint/scope-manager": "5.59.5",
+        "@typescript-eslint/type-utils": "5.59.5",
+        "@typescript-eslint/utils": "5.59.5",
         "debug": "^4.3.4",
         "grapheme-splitter": "^1.0.4",
         "ignore": "^5.2.0",
@@ -4208,11 +4208,11 @@
       }
     },
     "node_modules/@typescript-eslint/experimental-utils": {
-      "version": "5.59.2",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.59.2.tgz",
-      "integrity": "sha512-JLw2UImsjHDuVukpA8Nt+UK7JKE/LQAeV3tU5f7wJo2/NNYVwcakzkWjoYzu/2qzWY/Z9c7zojngNDfecNt92g==",
+      "version": "5.59.5",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.59.5.tgz",
+      "integrity": "sha512-ArcSSBifznsKNA/p4h2w3Olt/T8AZf3bNglxD8OnuTsSDJbRpjPPmI8qpr6ijyvk1J/T3GMJHwRIluS/Kuz9kA==",
       "dependencies": {
-        "@typescript-eslint/utils": "5.59.2"
+        "@typescript-eslint/utils": "5.59.5"
       },
       "engines": {
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@@ -4226,13 +4226,13 @@
       }
     },
     "node_modules/@typescript-eslint/parser": {
-      "version": "5.59.2",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.59.2.tgz",
-      "integrity": "sha512-uq0sKyw6ao1iFOZZGk9F8Nro/8+gfB5ezl1cA06SrqbgJAt0SRoFhb9pXaHvkrxUpZaoLxt8KlovHNk8Gp6/HQ==",
+      "version": "5.59.5",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.59.5.tgz",
+      "integrity": "sha512-NJXQC4MRnF9N9yWqQE2/KLRSOLvrrlZb48NGVfBa+RuPMN6B7ZcK5jZOvhuygv4D64fRKnZI4L4p8+M+rfeQuw==",
       "dependencies": {
-        "@typescript-eslint/scope-manager": "5.59.2",
-        "@typescript-eslint/types": "5.59.2",
-        "@typescript-eslint/typescript-estree": "5.59.2",
+        "@typescript-eslint/scope-manager": "5.59.5",
+        "@typescript-eslint/types": "5.59.5",
+        "@typescript-eslint/typescript-estree": "5.59.5",
         "debug": "^4.3.4"
       },
       "engines": {
@@ -4252,12 +4252,12 @@
       }
     },
     "node_modules/@typescript-eslint/scope-manager": {
-      "version": "5.59.2",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.59.2.tgz",
-      "integrity": "sha512-dB1v7ROySwQWKqQ8rEWcdbTsFjh2G0vn8KUyvTXdPoyzSL6lLGkiXEV5CvpJsEe9xIdKV+8Zqb7wif2issoOFA==",
+      "version": "5.59.5",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.59.5.tgz",
+      "integrity": "sha512-jVecWwnkX6ZgutF+DovbBJirZcAxgxC0EOHYt/niMROf8p4PwxxG32Qdhj/iIQQIuOflLjNkxoXyArkcIP7C3A==",
       "dependencies": {
-        "@typescript-eslint/types": "5.59.2",
-        "@typescript-eslint/visitor-keys": "5.59.2"
+        "@typescript-eslint/types": "5.59.5",
+        "@typescript-eslint/visitor-keys": "5.59.5"
       },
       "engines": {
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@@ -4268,12 +4268,12 @@
       }
     },
     "node_modules/@typescript-eslint/type-utils": {
-      "version": "5.59.2",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.59.2.tgz",
-      "integrity": "sha512-b1LS2phBOsEy/T381bxkkywfQXkV1dWda/z0PhnIy3bC5+rQWQDS7fk9CSpcXBccPY27Z6vBEuaPBCKCgYezyQ==",
+      "version": "5.59.5",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.59.5.tgz",
+      "integrity": "sha512-4eyhS7oGym67/pSxA2mmNq7X164oqDYNnZCUayBwJZIRVvKpBCMBzFnFxjeoDeShjtO6RQBHBuwybuX3POnDqg==",
       "dependencies": {
-        "@typescript-eslint/typescript-estree": "5.59.2",
-        "@typescript-eslint/utils": "5.59.2",
+        "@typescript-eslint/typescript-estree": "5.59.5",
+        "@typescript-eslint/utils": "5.59.5",
         "debug": "^4.3.4",
         "tsutils": "^3.21.0"
       },
@@ -4294,9 +4294,9 @@
       }
     },
     "node_modules/@typescript-eslint/types": {
-      "version": "5.59.2",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.59.2.tgz",
-      "integrity": "sha512-LbJ/HqoVs2XTGq5shkiKaNTuVv5tTejdHgfdjqRUGdYhjW1crm/M7og2jhVskMt8/4wS3T1+PfFvL1K3wqYj4w==",
+      "version": "5.59.5",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.59.5.tgz",
+      "integrity": "sha512-xkfRPHbqSH4Ggx4eHRIO/eGL8XL4Ysb4woL8c87YuAo8Md7AUjyWKa9YMwTL519SyDPrfEgKdewjkxNCVeJW7w==",
       "engines": {
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
       },
@@ -4306,12 +4306,12 @@
       }
     },
     "node_modules/@typescript-eslint/typescript-estree": {
-      "version": "5.59.2",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.2.tgz",
-      "integrity": "sha512-+j4SmbwVmZsQ9jEyBMgpuBD0rKwi9RxRpjX71Brr73RsYnEr3Lt5QZ624Bxphp8HUkSKfqGnPJp1kA5nl0Sh7Q==",
+      "version": "5.59.5",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.5.tgz",
+      "integrity": "sha512-+XXdLN2CZLZcD/mO7mQtJMvCkzRfmODbeSKuMY/yXbGkzvA9rJyDY5qDYNoiz2kP/dmyAxXquL2BvLQLJFPQIg==",
       "dependencies": {
-        "@typescript-eslint/types": "5.59.2",
-        "@typescript-eslint/visitor-keys": "5.59.2",
+        "@typescript-eslint/types": "5.59.5",
+        "@typescript-eslint/visitor-keys": "5.59.5",
         "debug": "^4.3.4",
         "globby": "^11.1.0",
         "is-glob": "^4.0.3",
@@ -4332,16 +4332,16 @@
       }
     },
     "node_modules/@typescript-eslint/utils": {
-      "version": "5.59.2",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.59.2.tgz",
-      "integrity": "sha512-kSuF6/77TZzyGPhGO4uVp+f0SBoYxCDf+lW3GKhtKru/L8k/Hd7NFQxyWUeY7Z/KGB2C6Fe3yf2vVi4V9TsCSQ==",
+      "version": "5.59.5",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.59.5.tgz",
+      "integrity": "sha512-sCEHOiw+RbyTii9c3/qN74hYDPNORb8yWCoPLmB7BIflhplJ65u2PBpdRla12e3SSTJ2erRkPjz7ngLHhUegxA==",
       "dependencies": {
         "@eslint-community/eslint-utils": "^4.2.0",
         "@types/json-schema": "^7.0.9",
         "@types/semver": "^7.3.12",
-        "@typescript-eslint/scope-manager": "5.59.2",
-        "@typescript-eslint/types": "5.59.2",
-        "@typescript-eslint/typescript-estree": "5.59.2",
+        "@typescript-eslint/scope-manager": "5.59.5",
+        "@typescript-eslint/types": "5.59.5",
+        "@typescript-eslint/typescript-estree": "5.59.5",
         "eslint-scope": "^5.1.1",
         "semver": "^7.3.7"
       },
@@ -4377,11 +4377,11 @@
       }
     },
     "node_modules/@typescript-eslint/visitor-keys": {
-      "version": "5.59.2",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.2.tgz",
-      "integrity": "sha512-EEpsO8m3RASrKAHI9jpavNv9NlEUebV4qmF1OWxSTtKSFBpC1NCmWazDQHFivRf0O1DV11BA645yrLEVQ0/Lig==",
+      "version": "5.59.5",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.5.tgz",
+      "integrity": "sha512-qL+Oz+dbeBRTeyJTIy0eniD3uvqU7x+y1QceBismZ41hd4aBSRh8UAw4pZP0+XzLuPZmx4raNMq/I+59W2lXKA==",
       "dependencies": {
-        "@typescript-eslint/types": "5.59.2",
+        "@typescript-eslint/types": "5.59.5",
         "eslint-visitor-keys": "^3.3.0"
       },
       "engines": {
@@ -4393,133 +4393,133 @@
       }
     },
     "node_modules/@webassemblyjs/ast": {
-      "version": "1.11.5",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.5.tgz",
-      "integrity": "sha512-LHY/GSAZZRpsNQH+/oHqhRQ5FT7eoULcBqgfyTB5nQHogFnK3/7QoN7dLnwSE/JkUAF0SrRuclT7ODqMFtWxxQ==",
+      "version": "1.11.6",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz",
+      "integrity": "sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q==",
       "dependencies": {
-        "@webassemblyjs/helper-numbers": "1.11.5",
-        "@webassemblyjs/helper-wasm-bytecode": "1.11.5"
+        "@webassemblyjs/helper-numbers": "1.11.6",
+        "@webassemblyjs/helper-wasm-bytecode": "1.11.6"
       }
     },
     "node_modules/@webassemblyjs/floating-point-hex-parser": {
-      "version": "1.11.5",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.5.tgz",
-      "integrity": "sha512-1j1zTIC5EZOtCplMBG/IEwLtUojtwFVwdyVMbL/hwWqbzlQoJsWCOavrdnLkemwNoC/EOwtUFch3fuo+cbcXYQ=="
+      "version": "1.11.6",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz",
+      "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw=="
     },
     "node_modules/@webassemblyjs/helper-api-error": {
-      "version": "1.11.5",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.5.tgz",
-      "integrity": "sha512-L65bDPmfpY0+yFrsgz8b6LhXmbbs38OnwDCf6NpnMUYqa+ENfE5Dq9E42ny0qz/PdR0LJyq/T5YijPnU8AXEpA=="
+      "version": "1.11.6",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz",
+      "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q=="
     },
     "node_modules/@webassemblyjs/helper-buffer": {
-      "version": "1.11.5",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.5.tgz",
-      "integrity": "sha512-fDKo1gstwFFSfacIeH5KfwzjykIE6ldh1iH9Y/8YkAZrhmu4TctqYjSh7t0K2VyDSXOZJ1MLhht/k9IvYGcIxg=="
+      "version": "1.11.6",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.6.tgz",
+      "integrity": "sha512-z3nFzdcp1mb8nEOFFk8DrYLpHvhKC3grJD2ardfKOzmbmJvEf/tPIqCY+sNcwZIY8ZD7IkB2l7/pqhUhqm7hLA=="
     },
     "node_modules/@webassemblyjs/helper-numbers": {
-      "version": "1.11.5",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.5.tgz",
-      "integrity": "sha512-DhykHXM0ZABqfIGYNv93A5KKDw/+ywBFnuWybZZWcuzWHfbp21wUfRkbtz7dMGwGgT4iXjWuhRMA2Mzod6W4WA==",
+      "version": "1.11.6",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz",
+      "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==",
       "dependencies": {
-        "@webassemblyjs/floating-point-hex-parser": "1.11.5",
-        "@webassemblyjs/helper-api-error": "1.11.5",
+        "@webassemblyjs/floating-point-hex-parser": "1.11.6",
+        "@webassemblyjs/helper-api-error": "1.11.6",
         "@xtuc/long": "4.2.2"
       }
     },
     "node_modules/@webassemblyjs/helper-wasm-bytecode": {
-      "version": "1.11.5",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.5.tgz",
-      "integrity": "sha512-oC4Qa0bNcqnjAowFn7MPCETQgDYytpsfvz4ujZz63Zu/a/v71HeCAAmZsgZ3YVKec3zSPYytG3/PrRCqbtcAvA=="
+      "version": "1.11.6",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz",
+      "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA=="
     },
     "node_modules/@webassemblyjs/helper-wasm-section": {
-      "version": "1.11.5",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.5.tgz",
-      "integrity": "sha512-uEoThA1LN2NA+K3B9wDo3yKlBfVtC6rh0i4/6hvbz071E8gTNZD/pT0MsBf7MeD6KbApMSkaAK0XeKyOZC7CIA==",
+      "version": "1.11.6",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.6.tgz",
+      "integrity": "sha512-LPpZbSOwTpEC2cgn4hTydySy1Ke+XEu+ETXuoyvuyezHO3Kjdu90KK95Sh9xTbmjrCsUwvWwCOQQNta37VrS9g==",
       "dependencies": {
-        "@webassemblyjs/ast": "1.11.5",
-        "@webassemblyjs/helper-buffer": "1.11.5",
-        "@webassemblyjs/helper-wasm-bytecode": "1.11.5",
-        "@webassemblyjs/wasm-gen": "1.11.5"
+        "@webassemblyjs/ast": "1.11.6",
+        "@webassemblyjs/helper-buffer": "1.11.6",
+        "@webassemblyjs/helper-wasm-bytecode": "1.11.6",
+        "@webassemblyjs/wasm-gen": "1.11.6"
       }
     },
     "node_modules/@webassemblyjs/ieee754": {
-      "version": "1.11.5",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.5.tgz",
-      "integrity": "sha512-37aGq6qVL8A8oPbPrSGMBcp38YZFXcHfiROflJn9jxSdSMMM5dS5P/9e2/TpaJuhE+wFrbukN2WI6Hw9MH5acg==",
+      "version": "1.11.6",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz",
+      "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==",
       "dependencies": {
         "@xtuc/ieee754": "^1.2.0"
       }
     },
     "node_modules/@webassemblyjs/leb128": {
-      "version": "1.11.5",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.5.tgz",
-      "integrity": "sha512-ajqrRSXaTJoPW+xmkfYN6l8VIeNnR4vBOTQO9HzR7IygoCcKWkICbKFbVTNMjMgMREqXEr0+2M6zukzM47ZUfQ==",
+      "version": "1.11.6",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz",
+      "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==",
       "dependencies": {
         "@xtuc/long": "4.2.2"
       }
     },
     "node_modules/@webassemblyjs/utf8": {
-      "version": "1.11.5",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.5.tgz",
-      "integrity": "sha512-WiOhulHKTZU5UPlRl53gHR8OxdGsSOxqfpqWeA2FmcwBMaoEdz6b2x2si3IwC9/fSPLfe8pBMRTHVMk5nlwnFQ=="
+      "version": "1.11.6",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz",
+      "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA=="
     },
     "node_modules/@webassemblyjs/wasm-edit": {
-      "version": "1.11.5",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.5.tgz",
-      "integrity": "sha512-C0p9D2fAu3Twwqvygvf42iGCQ4av8MFBLiTb+08SZ4cEdwzWx9QeAHDo1E2k+9s/0w1DM40oflJOpkZ8jW4HCQ==",
+      "version": "1.11.6",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.6.tgz",
+      "integrity": "sha512-Ybn2I6fnfIGuCR+Faaz7YcvtBKxvoLV3Lebn1tM4o/IAJzmi9AWYIPWpyBfU8cC+JxAO57bk4+zdsTjJR+VTOw==",
       "dependencies": {
-        "@webassemblyjs/ast": "1.11.5",
-        "@webassemblyjs/helper-buffer": "1.11.5",
-        "@webassemblyjs/helper-wasm-bytecode": "1.11.5",
-        "@webassemblyjs/helper-wasm-section": "1.11.5",
-        "@webassemblyjs/wasm-gen": "1.11.5",
-        "@webassemblyjs/wasm-opt": "1.11.5",
-        "@webassemblyjs/wasm-parser": "1.11.5",
-        "@webassemblyjs/wast-printer": "1.11.5"
+        "@webassemblyjs/ast": "1.11.6",
+        "@webassemblyjs/helper-buffer": "1.11.6",
+        "@webassemblyjs/helper-wasm-bytecode": "1.11.6",
+        "@webassemblyjs/helper-wasm-section": "1.11.6",
+        "@webassemblyjs/wasm-gen": "1.11.6",
+        "@webassemblyjs/wasm-opt": "1.11.6",
+        "@webassemblyjs/wasm-parser": "1.11.6",
+        "@webassemblyjs/wast-printer": "1.11.6"
       }
     },
     "node_modules/@webassemblyjs/wasm-gen": {
-      "version": "1.11.5",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.5.tgz",
-      "integrity": "sha512-14vteRlRjxLK9eSyYFvw1K8Vv+iPdZU0Aebk3j6oB8TQiQYuO6hj9s4d7qf6f2HJr2khzvNldAFG13CgdkAIfA==",
+      "version": "1.11.6",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.6.tgz",
+      "integrity": "sha512-3XOqkZP/y6B4F0PBAXvI1/bky7GryoogUtfwExeP/v7Nzwo1QLcq5oQmpKlftZLbT+ERUOAZVQjuNVak6UXjPA==",
       "dependencies": {
-        "@webassemblyjs/ast": "1.11.5",
-        "@webassemblyjs/helper-wasm-bytecode": "1.11.5",
-        "@webassemblyjs/ieee754": "1.11.5",
-        "@webassemblyjs/leb128": "1.11.5",
-        "@webassemblyjs/utf8": "1.11.5"
+        "@webassemblyjs/ast": "1.11.6",
+        "@webassemblyjs/helper-wasm-bytecode": "1.11.6",
+        "@webassemblyjs/ieee754": "1.11.6",
+        "@webassemblyjs/leb128": "1.11.6",
+        "@webassemblyjs/utf8": "1.11.6"
       }
     },
     "node_modules/@webassemblyjs/wasm-opt": {
-      "version": "1.11.5",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.5.tgz",
-      "integrity": "sha512-tcKwlIXstBQgbKy1MlbDMlXaxpucn42eb17H29rawYLxm5+MsEmgPzeCP8B1Cl69hCice8LeKgZpRUAPtqYPgw==",
+      "version": "1.11.6",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.6.tgz",
+      "integrity": "sha512-cOrKuLRE7PCe6AsOVl7WasYf3wbSo4CeOk6PkrjS7g57MFfVUF9u6ysQBBODX0LdgSvQqRiGz3CXvIDKcPNy4g==",
       "dependencies": {
-        "@webassemblyjs/ast": "1.11.5",
-        "@webassemblyjs/helper-buffer": "1.11.5",
-        "@webassemblyjs/wasm-gen": "1.11.5",
-        "@webassemblyjs/wasm-parser": "1.11.5"
+        "@webassemblyjs/ast": "1.11.6",
+        "@webassemblyjs/helper-buffer": "1.11.6",
+        "@webassemblyjs/wasm-gen": "1.11.6",
+        "@webassemblyjs/wasm-parser": "1.11.6"
       }
     },
     "node_modules/@webassemblyjs/wasm-parser": {
-      "version": "1.11.5",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.5.tgz",
-      "integrity": "sha512-SVXUIwsLQlc8srSD7jejsfTU83g7pIGr2YYNb9oHdtldSxaOhvA5xwvIiWIfcX8PlSakgqMXsLpLfbbJ4cBYew==",
+      "version": "1.11.6",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.6.tgz",
+      "integrity": "sha512-6ZwPeGzMJM3Dqp3hCsLgESxBGtT/OeCvCZ4TA1JUPYgmhAx38tTPR9JaKy0S5H3evQpO/h2uWs2j6Yc/fjkpTQ==",
       "dependencies": {
-        "@webassemblyjs/ast": "1.11.5",
-        "@webassemblyjs/helper-api-error": "1.11.5",
-        "@webassemblyjs/helper-wasm-bytecode": "1.11.5",
-        "@webassemblyjs/ieee754": "1.11.5",
-        "@webassemblyjs/leb128": "1.11.5",
-        "@webassemblyjs/utf8": "1.11.5"
+        "@webassemblyjs/ast": "1.11.6",
+        "@webassemblyjs/helper-api-error": "1.11.6",
+        "@webassemblyjs/helper-wasm-bytecode": "1.11.6",
+        "@webassemblyjs/ieee754": "1.11.6",
+        "@webassemblyjs/leb128": "1.11.6",
+        "@webassemblyjs/utf8": "1.11.6"
       }
     },
     "node_modules/@webassemblyjs/wast-printer": {
-      "version": "1.11.5",
-      "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.5.tgz",
-      "integrity": "sha512-f7Pq3wvg3GSPUPzR0F6bmI89Hdb+u9WXrSKc4v+N0aV0q6r42WoF92Jp2jEorBEBRoRNXgjp53nBniDXcqZYPA==",
+      "version": "1.11.6",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.6.tgz",
+      "integrity": "sha512-JM7AhRcE+yW2GWYaKeHL5vt4xqee5N2WcezptmgyhNS+ScggqcT1OtXykhAb13Sn5Yas0j2uv9tHgrjwvzAP4A==",
       "dependencies": {
-        "@webassemblyjs/ast": "1.11.5",
+        "@webassemblyjs/ast": "1.11.6",
         "@xtuc/long": "4.2.2"
       }
     },
@@ -4582,9 +4582,9 @@
       }
     },
     "node_modules/acorn-import-assertions": {
-      "version": "1.8.0",
-      "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz",
-      "integrity": "sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==",
+      "version": "1.9.0",
+      "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz",
+      "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==",
       "peerDependencies": {
         "acorn": "^8"
       }
@@ -5511,9 +5511,9 @@
       }
     },
     "node_modules/caniuse-lite": {
-      "version": "1.0.30001486",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001486.tgz",
-      "integrity": "sha512-uv7/gXuHi10Whlj0pp5q/tsK/32J2QSqVRKQhs2j8VsDCjgyruAh/eEXHF822VqO9yT6iZKw3nRwZRSPBE9OQg==",
+      "version": "1.0.30001487",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001487.tgz",
+      "integrity": "sha512-83564Z3yWGqXsh2vaH/mhXfEM0wX+NlBCm1jYHOb97TrTWJEmPTccZgeLTPBUUb0PNVo+oomb7wkimZBIERClA==",
       "funding": [
         {
           "type": "opencollective",
@@ -6171,13 +6171,19 @@
       }
     },
     "node_modules/cssdb": {
-      "version": "7.5.4",
-      "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.5.4.tgz",
-      "integrity": "sha512-fGD+J6Jlq+aurfE1VDXlLS4Pt0VtNlu2+YgfGOdMxRyl/HQ9bDiHTwSck1Yz8A97Dt/82izSK6Bp/4nVqacOsg==",
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/csstools"
-      }
+      "version": "7.6.0",
+      "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.6.0.tgz",
+      "integrity": "sha512-Nna7rph8V0jC6+JBY4Vk4ndErUmfJfV6NJCaZdurL0omggabiy+QB2HCQtu5c/ACLZ0I7REv7A4QyPIoYzZx0w==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        }
+      ]
     },
     "node_modules/cssesc": {
       "version": "3.0.0",
@@ -6743,9 +6749,9 @@
       }
     },
     "node_modules/electron-to-chromium": {
-      "version": "1.4.385",
-      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.385.tgz",
-      "integrity": "sha512-L9zlje9bIw0h+CwPQumiuVlfMcV4boxRjFIWDcLfFqTZNbkwOExBzfmswytHawObQX4OUhtNv8gIiB21kOurIg=="
+      "version": "1.4.394",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.394.tgz",
+      "integrity": "sha512-0IbC2cfr8w5LxTz+nmn2cJTGafsK9iauV2r5A5scfzyovqLrxuLoxOHE5OBobP3oVIggJT+0JfKnw9sm87c8Hw=="
     },
     "node_modules/emittery": {
       "version": "0.8.1",
@@ -6780,9 +6786,9 @@
       }
     },
     "node_modules/enhanced-resolve": {
-      "version": "5.13.0",
-      "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.13.0.tgz",
-      "integrity": "sha512-eyV8f0y1+bzyfh8xAwW/WTSZpLbjhqc4ne9eGSH4Zo2ejdyiNG9pU6mf9DG8a7+Auk6MFTlNOT4Y2y/9k8GKVg==",
+      "version": "5.14.0",
+      "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.14.0.tgz",
+      "integrity": "sha512-+DCows0XNwLDcUhbFJPdlQEVnT2zXlCv7hPxemTz86/O+B/hCQ+mb7ydkPKiflpVraqLPCAfu7lDy+hBXueojw==",
       "dependencies": {
         "graceful-fs": "^4.2.4",
         "tapable": "^2.2.0"
@@ -7361,9 +7367,9 @@
       }
     },
     "node_modules/eslint-plugin-testing-library": {
-      "version": "5.10.3",
-      "resolved": "https://registry.npmjs.org/eslint-plugin-testing-library/-/eslint-plugin-testing-library-5.10.3.tgz",
-      "integrity": "sha512-0yhsKFsjHLud5PM+f2dWr9K3rqYzMy4cSHs3lcmFYMa1CdSzRvHGgXvsFarBjZ41gU8jhTdMIkg8jHLxGJqLqw==",
+      "version": "5.11.0",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-testing-library/-/eslint-plugin-testing-library-5.11.0.tgz",
+      "integrity": "sha512-ELY7Gefo+61OfXKlQeXNIDVVLPcvKTeiQOoMZG9TeuWa7Ln4dUNRv8JdRWBQI9Mbb427XGlVB1aa1QPZxBJM8Q==",
       "dependencies": {
         "@typescript-eslint/utils": "^5.58.0"
       },
@@ -8342,12 +8348,13 @@
       }
     },
     "node_modules/get-intrinsic": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.0.tgz",
-      "integrity": "sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==",
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz",
+      "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==",
       "dependencies": {
         "function-bind": "^1.1.1",
         "has": "^1.0.3",
+        "has-proto": "^1.0.1",
         "has-symbols": "^1.0.3"
       },
       "funding": {
@@ -9551,14 +9558,14 @@
       }
     },
     "node_modules/jake": {
-      "version": "10.8.5",
-      "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.5.tgz",
-      "integrity": "sha512-sVpxYeuAhWt0OTWITwT98oyV0GsXyMlXCF+3L1SuafBVUIr/uILGRB+NqwkzhgXKvoJpDIpQvqkUALgdmQsQxw==",
+      "version": "10.8.6",
+      "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.6.tgz",
+      "integrity": "sha512-G43Ub9IYEFfu72sua6rzooi8V8Gz2lkfk48rW20vEWCGizeaEPlKB1Kh8JIA84yQbiAEfqlPmSpGgCKKxH3rDA==",
       "dependencies": {
         "async": "^3.2.3",
         "chalk": "^4.0.2",
-        "filelist": "^1.0.1",
-        "minimatch": "^3.0.4"
+        "filelist": "^1.0.4",
+        "minimatch": "^3.1.2"
       },
       "bin": {
         "jake": "bin/cli.js"
@@ -15068,9 +15075,9 @@
       }
     },
     "node_modules/semver": {
-      "version": "7.5.0",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.0.tgz",
-      "integrity": "sha512-+XC0AD/R7Q2mPSRuy2Id0+CGTZ98+8f+KvwirxOKIEyid+XSx6HbC63p+O4IndTHuX5Z+JxQ0TghCkO5Cg/2HA==",
+      "version": "7.5.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.1.tgz",
+      "integrity": "sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw==",
       "dependencies": {
         "lru-cache": "^6.0.0"
       },
@@ -15969,9 +15976,9 @@
       }
     },
     "node_modules/terser": {
-      "version": "5.17.1",
-      "resolved": "https://registry.npmjs.org/terser/-/terser-5.17.1.tgz",
-      "integrity": "sha512-hVl35zClmpisy6oaoKALOpS0rDYLxRFLHhRuDlEGTKey9qHjS1w9GMORjuwIMt70Wan4lwsLYyWDVnWgF+KUEw==",
+      "version": "5.17.3",
+      "resolved": "https://registry.npmjs.org/terser/-/terser-5.17.3.tgz",
+      "integrity": "sha512-AudpAZKmZHkG9jueayypz4duuCFJMMNGRMwaPvQKWfxKedh8Z2x3OCoDqIIi1xx5+iwx1u6Au8XQcc9Lke65Yg==",
       "dependencies": {
         "@jridgewell/source-map": "^0.3.2",
         "acorn": "^8.5.0",
@@ -16548,9 +16555,9 @@
       }
     },
     "node_modules/webpack": {
-      "version": "5.82.0",
-      "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.82.0.tgz",
-      "integrity": "sha512-iGNA2fHhnDcV1bONdUu554eZx+XeldsaeQ8T67H6KKHl2nUSwX8Zm7cmzOA46ox/X1ARxf7Bjv8wQ/HsB5fxBg==",
+      "version": "5.82.1",
+      "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.82.1.tgz",
+      "integrity": "sha512-C6uiGQJ+Gt4RyHXXYt+v9f+SN1v83x68URwgxNQ98cvH8kxiuywWGP4XeNZ1paOzZ63aY3cTciCEQJNFUljlLw==",
       "dependencies": {
         "@types/eslint-scope": "^3.7.3",
         "@types/estree": "^1.0.0",
@@ -16561,7 +16568,7 @@
         "acorn-import-assertions": "^1.7.6",
         "browserslist": "^4.14.5",
         "chrome-trace-event": "^1.0.2",
-        "enhanced-resolve": "^5.13.0",
+        "enhanced-resolve": "^5.14.0",
         "es-module-lexer": "^1.2.1",
         "eslint-scope": "5.1.1",
         "events": "^3.2.0",

+ 5 - 2
web/src/app/Api.js

@@ -21,8 +21,11 @@ class Api {
         const headers = maybeWithAuth({}, user);
         console.log(`[Api] Polling ${url}`);
         for await (let line of fetchLinesIterator(url, headers)) {
-            console.log(`[Api, ${shortUrl}] Received message ${line}`);
-            messages.push(JSON.parse(line));
+            const message = JSON.parse(line);
+            if (message.id) {
+                console.log(`[Api, ${shortUrl}] Received message ${line}`);
+                messages.push(message);
+            }
         }
         return messages;
     }

+ 1 - 1
web/src/components/Account.js

@@ -585,7 +585,7 @@ const Stats = () => {
                     description={t("account_usage_attachment_storage_description", {
                         filesize: formatBytes(account.limits.attachment_file_size),
                         expiry: humanizeDuration(account.limits.attachment_expiry_duration * 1000, {
-                            language: i18n.language,
+                            language: i18n.resolvedLanguage,
                             fallbacks: ["en"]
                         })
                     })}

+ 3 - 3
web/src/components/Preferences.js

@@ -436,7 +436,7 @@ const Appearance = () => {
 const Language = () => {
     const { t, i18n } = useTranslation();
     const labelId = "prefLanguage";
-    const lang = i18n.language ?? "en";
+    const lang = i18n.resolvedLanguage ?? "en";
 
     // Country flags are displayed using emoji. Emoji rendering is handled by platform fonts.
     // Windows in particular does not yet play nicely with flag emoji so for now, hide flags on Windows.
@@ -541,8 +541,8 @@ const ReservationsTable = (props) => {
     const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
     const { subscriptions } = useOutletContext();
     const localSubscriptions = (subscriptions?.length > 0)
-        ? Object.assign(...subscriptions.filter(s => s.baseUrl === config.base_url).map(s => ({[s.topic]: s})))
-        : [];
+        ? Object.assign({}, ...subscriptions.filter(s => s.baseUrl === config.base_url).map(s => ({[s.topic]: s})))
+        : {};
 
     const handleEditClick = (reservation) => {
         setDialogKey(prev => prev+1);

+ 1 - 1
web/src/components/ReserveDialogs.js

@@ -34,7 +34,7 @@ export const ReserveAddDialog = (props) => {
     const handleSubmit = async () => {
         try {
             await accountApi.upsertReservation(topic, everyone);
-            console.debug(`[ReserveAddDialog] Added reservation for topic ${t}: ${everyone}`);
+            console.debug(`[ReserveAddDialog] Added reservation for topic ${topic}: ${everyone}`);
         } catch (e) {
             console.log(`[ReserveAddDialog] Error adding topic reservation.`, e);
             if (e instanceof UnauthorizedError) {

Неке датотеке нису приказане због велике количине промена